1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import { unionize, ofType, UnionOf } from "common/unionize";
6 import { TreeNode, initTreeNode, getNodeDescendants, TreeNodeStatus, getNode, TreePickerId, Tree } from 'models/tree';
7 import { CollectionFileType, createCollectionFilesTree } from "models/collection-file";
8 import { Dispatch } from 'redux';
9 import { RootState } from 'store/store';
10 import { getUserUuid } from "common/getuser";
11 import { ServiceRepository } from 'services/services';
12 import { FilterBuilder } from 'services/api/filter-builder';
13 import { pipe, values } from 'lodash/fp';
14 import { ResourceKind } from 'models/resource';
15 import { GroupContentsResource } from 'services/groups-service/groups-service';
16 import { getTreePicker, TreePicker } from './tree-picker';
17 import { ProjectsTreePickerItem } from './tree-picker-middleware';
18 import { OrderBuilder } from 'services/api/order-builder';
19 import { ProjectResource } from 'models/project';
20 import { mapTree } from '../../models/tree';
21 import { LinkResource, LinkClass } from "models/link";
22 import { mapTreeValues } from "models/tree";
23 import { sortFilesTree } from "services/collection-service/collection-service-files-response";
24 import { GroupClass, GroupResource } from "models/group";
26 export const treePickerActions = unionize({
27 LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
28 LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array<TreeNode<any>>, pickerId: string }>(),
29 APPEND_TREE_PICKER_NODE_SUBTREE: ofType<{ id: string, subtree: Tree<any>, pickerId: string }>(),
30 TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string, pickerId: string }>(),
31 EXPAND_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
32 ACTIVATE_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string, relatedTreePickers?: string[] }>(),
33 DEACTIVATE_TREE_PICKER_NODE: ofType<{ pickerId: string }>(),
34 TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string }>(),
35 SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(),
36 DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(),
37 EXPAND_TREE_PICKER_NODES: ofType<{ ids: string[], pickerId: string }>(),
38 RESET_TREE_PICKER: ofType<{ pickerId: string }>()
41 export type TreePickerAction = UnionOf<typeof treePickerActions>;
43 export interface LoadProjectParams {
44 includeCollections?: boolean;
45 includeDirectories?: boolean;
46 includeFiles?: boolean;
47 includeFilterGroups?: boolean;
48 options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; };
51 export const treePickerSearchActions = unionize({
52 SET_TREE_PICKER_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(),
53 SET_TREE_PICKER_COLLECTION_FILTER: ofType<{ pickerId: string, collectionFilterValue: string }>(),
54 SET_TREE_PICKER_LOAD_PARAMS: ofType<{ pickerId: string, params: LoadProjectParams }>(),
57 export type TreePickerSearchAction = UnionOf<typeof treePickerSearchActions>;
59 export const getProjectsTreePickerIds = (pickerId: string) => ({
60 home: `${pickerId}_home`,
61 shared: `${pickerId}_shared`,
62 favorites: `${pickerId}_favorites`,
63 publicFavorites: `${pickerId}_publicFavorites`,
64 search: `${pickerId}_search`,
67 export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
69 () => values(getProjectsTreePickerIds(pickerId)),
72 .map(id => getTreePicker<Value>(id)(state)),
75 .map(getNodeDescendants(''))
76 .reduce((allNodes, nodes) => allNodes.concat(nodes), []),
79 .reduce((map, node) =>
81 ? map.set(node.id, node)
82 : map, new Map<string, TreeNode<Value>>())
85 uniqueNodes => Array.from(uniqueNodes),
87 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
88 getAllNodes<Value>(pickerId, node => node.selected)(state);
90 export const initProjectsTreePicker = (pickerId: string) =>
91 async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
92 const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
93 dispatch<any>(initUserProject(home));
94 dispatch<any>(initSharedProject(shared));
95 dispatch<any>(initFavoritesProject(favorites));
96 dispatch<any>(initPublicFavoritesProject(publicFavorites));
97 dispatch<any>(initSearchProject(search));
100 interface ReceiveTreePickerDataParams<T> {
102 extractNodeData: (value: T) => { id: string, value: T, status?: TreeNodeStatus };
107 export const receiveTreePickerData = <T>(params: ReceiveTreePickerDataParams<T>) =>
108 (dispatch: Dispatch) => {
109 const { data, extractNodeData, id, pickerId, } = params;
110 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
112 nodes: data.map(item => initTreeNode(extractNodeData(item))),
115 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
118 interface LoadProjectParamsWithId extends LoadProjectParams {
121 loadShared?: boolean;
122 searchProjects?: boolean;
125 export const loadProject = (params: LoadProjectParamsWithId) =>
126 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
130 includeCollections = false,
131 includeDirectories = false,
132 includeFiles = false,
133 includeFilterGroups = false,
136 searchProjects = false
139 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
141 let filterB = new FilterBuilder();
143 filterB = (includeCollections && !searchProjects)
144 ? filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
145 : filterB.addIsA('uuid', [ResourceKind.PROJECT]);
147 const state = getState();
149 if (state.treePickerSearch.collectionFilterValues[pickerId]) {
150 filterB = filterB.addFullTextSearch(state.treePickerSearch.collectionFilterValues[pickerId], 'collections');
152 filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
155 if (searchProjects && state.treePickerSearch.projectSearchValues[pickerId]) {
156 filterB = filterB.addFullTextSearch(state.treePickerSearch.projectSearchValues[pickerId], 'groups');
159 const filters = filterB.getFilters();
161 const itemLimit = 200;
163 const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit });
165 if (itemsAvailable > itemLimit) {
167 uuid: "more-items-available",
168 kind: ResourceKind.WORKFLOW,
169 name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`,
174 modifiedByClientUuid: "",
175 modifiedByUserUuid: "",
182 dispatch<any>(receiveTreePickerData<GroupContentsResource>({
185 data: items.filter((item) => {
186 if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
190 if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
196 extractNodeData: item => (
197 item.uuid === "more-items-available" ?
201 status: TreeNodeStatus.LOADED
206 status: item.kind === ResourceKind.PROJECT
207 ? TreeNodeStatus.INITIAL
208 : includeDirectories || includeFiles
209 ? TreeNodeStatus.INITIAL
210 : TreeNodeStatus.LOADED
215 export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) =>
216 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
217 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
219 const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
222 const node = getNode(id)(picker);
223 if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
224 const files = (await services.collectionService.files(node.value.uuid))
227 (includeDirectories && file.type === CollectionFileType.DIRECTORY)
229 const tree = createCollectionFilesTree(files);
230 const sorted = sortFilesTree(tree);
231 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
234 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
237 subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
240 dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
246 export const initUserProject = (pickerId: string) =>
247 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
248 const uuid = getUserUuid(getState());
250 dispatch(receiveTreePickerData({
253 data: [{ uuid, name: 'Home Projects' }],
254 extractNodeData: value => ({
256 status: TreeNodeStatus.INITIAL,
262 export const loadUserProject = (pickerId: string, includeCollections = false, includeDirectories = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
263 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
264 const uuid = getUserUuid(getState());
266 dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
270 export const SHARED_PROJECT_ID = 'Shared with me';
271 export const initSharedProject = (pickerId: string) =>
272 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
273 dispatch(receiveTreePickerData({
276 data: [{ uuid: SHARED_PROJECT_ID, name: SHARED_PROJECT_ID }],
277 extractNodeData: value => ({
279 status: TreeNodeStatus.INITIAL,
285 export const FAVORITES_PROJECT_ID = 'Favorites';
286 export const initFavoritesProject = (pickerId: string) =>
287 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
288 dispatch(receiveTreePickerData({
291 data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
292 extractNodeData: value => ({
294 status: TreeNodeStatus.INITIAL,
300 export const PUBLIC_FAVORITES_PROJECT_ID = 'Public Favorites';
301 export const initPublicFavoritesProject = (pickerId: string) =>
302 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
303 dispatch(receiveTreePickerData({
306 data: [{ uuid: PUBLIC_FAVORITES_PROJECT_ID, name: PUBLIC_FAVORITES_PROJECT_ID }],
307 extractNodeData: value => ({
309 status: TreeNodeStatus.INITIAL,
315 export const SEARCH_PROJECT_ID = 'Search all Projects';
316 export const initSearchProject = (pickerId: string) =>
317 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
318 dispatch(receiveTreePickerData({
321 data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
322 extractNodeData: value => ({
324 status: TreeNodeStatus.INITIAL,
331 interface LoadFavoritesProjectParams {
333 includeCollections?: boolean;
334 includeDirectories?: boolean;
335 includeFiles?: boolean;
336 options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
339 export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
340 options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) =>
341 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
342 const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
343 const uuid = getUserUuid(getState());
345 const filters = pipe(
346 (fb: FilterBuilder) => includeCollections
347 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
348 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
349 fb => fb.getFilters(),
350 )(new FilterBuilder());
352 const { items } = await services.favoriteService.list(uuid, { filters }, options.showOnlyOwned);
354 dispatch<any>(receiveTreePickerData<GroupContentsResource>({
357 data: items.filter((item) => {
358 if (options.showOnlyWritable && (item as GroupResource).writableBy && (item as GroupResource).writableBy.indexOf(uuid) === -1) {
362 if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
368 extractNodeData: item => ({
371 status: item.kind === ResourceKind.PROJECT
372 ? TreeNodeStatus.INITIAL
373 : includeDirectories || includeFiles
374 ? TreeNodeStatus.INITIAL
375 : TreeNodeStatus.LOADED
381 export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =>
382 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
383 const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
384 const uuidPrefix = getState().auth.config.uuidPrefix;
385 const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
387 const filters = pipe(
388 (fb: FilterBuilder) => includeCollections
389 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
390 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
392 .addEqual('link_class', LinkClass.STAR)
393 .addEqual('owner_uuid', publicProjectUuid)
395 )(new FilterBuilder());
397 const { items } = await services.linkService.list({ filters });
399 dispatch<any>(receiveTreePickerData<LinkResource>({
400 id: 'Public Favorites',
402 data: items.filter(item => {
403 if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
409 extractNodeData: item => ({
412 status: item.headKind === ResourceKind.PROJECT
413 ? TreeNodeStatus.INITIAL
414 : includeDirectories || includeFiles
415 ? TreeNodeStatus.INITIAL
416 : TreeNodeStatus.LOADED
421 export const receiveTreePickerProjectsData = (id: string, projects: ProjectResource[], pickerId: string) =>
422 (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
423 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
425 nodes: projects.map(project => initTreeNode({ id: project.uuid, value: project })),
429 dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
432 export const loadProjectTreePickerProjects = (id: string) =>
433 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
434 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PROJECTS }));
437 const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
438 const { items } = await services.projectService.list(buildParams(ownerUuid));
440 dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
443 export const loadFavoriteTreePickerProjects = (id: string) =>
444 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
445 const parentId = getUserUuid(getState()) || '';
448 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.FAVORITES }));
449 const { items } = await services.favoriteService.list(parentId);
450 dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
452 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.FAVORITES }));
453 const { items } = await services.projectService.list(buildParams(id));
454 dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.FAVORITES));
459 export const loadPublicFavoriteTreePickerProjects = (id: string) =>
460 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
461 const parentId = getUserUuid(getState()) || '';
464 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.PUBLIC_FAVORITES }));
465 const { items } = await services.favoriteService.list(parentId);
466 dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.PUBLIC_FAVORITES));
468 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PUBLIC_FAVORITES }));
469 const { items } = await services.projectService.list(buildParams(id));
470 dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PUBLIC_FAVORITES));
475 const buildParams = (ownerUuid: string) => {
477 filters: new FilterBuilder()
478 .addEqual('owner_uuid', ownerUuid)
480 order: new OrderBuilder<ProjectResource>()