20031: Add includeDirectories flag to ProjectsTreePicker and add collection/directory...
[arvados-workbench2.git] / src / store / tree-picker / tree-picker-actions.ts
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
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";
25
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 }>()
39 });
40
41 export type TreePickerAction = UnionOf<typeof treePickerActions>;
42
43 export interface LoadProjectParams {
44     includeCollections?: boolean;
45     includeDirectories?: boolean;
46     includeFiles?: boolean;
47     includeFilterGroups?: boolean;
48     options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; };
49 }
50
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 }>(),
55 });
56
57 export type TreePickerSearchAction = UnionOf<typeof treePickerSearchActions>;
58
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`,
65 });
66
67 export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
68     pipe(
69         () => values(getProjectsTreePickerIds(pickerId)),
70
71         ids => ids
72             .map(id => getTreePicker<Value>(id)(state)),
73
74         trees => trees
75             .map(getNodeDescendants(''))
76             .reduce((allNodes, nodes) => allNodes.concat(nodes), []),
77
78         allNodes => allNodes
79             .reduce((map, node) =>
80                 filter(node)
81                     ? map.set(node.id, node)
82                     : map, new Map<string, TreeNode<Value>>())
83             .values(),
84
85         uniqueNodes => Array.from(uniqueNodes),
86     )();
87 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
88     getAllNodes<Value>(pickerId, node => node.selected)(state);
89
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));
98     };
99
100 interface ReceiveTreePickerDataParams<T> {
101     data: T[];
102     extractNodeData: (value: T) => { id: string, value: T, status?: TreeNodeStatus };
103     id: string;
104     pickerId: string;
105 }
106
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({
111             id,
112             nodes: data.map(item => initTreeNode(extractNodeData(item))),
113             pickerId,
114         }));
115         dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
116     };
117
118 interface LoadProjectParamsWithId extends LoadProjectParams {
119     id: string;
120     pickerId: string;
121     loadShared?: boolean;
122     searchProjects?: boolean;
123 }
124
125 export const loadProject = (params: LoadProjectParamsWithId) =>
126     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
127         const {
128             id,
129             pickerId,
130             includeCollections = false,
131             includeDirectories = false,
132             includeFiles = false,
133             includeFilterGroups = false,
134             loadShared = false,
135             options,
136             searchProjects = false
137         } = params;
138
139         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
140
141         let filterB = new FilterBuilder();
142
143         filterB = (includeCollections && !searchProjects)
144             ? filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
145             : filterB.addIsA('uuid', [ResourceKind.PROJECT]);
146
147         const state = getState();
148
149         if (state.treePickerSearch.collectionFilterValues[pickerId]) {
150             filterB = filterB.addFullTextSearch(state.treePickerSearch.collectionFilterValues[pickerId], 'collections');
151         } else {
152             filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
153         }
154
155         if (searchProjects && state.treePickerSearch.projectSearchValues[pickerId]) {
156             filterB = filterB.addFullTextSearch(state.treePickerSearch.projectSearchValues[pickerId], 'groups');
157         }
158
159         const filters = filterB.getFilters();
160
161         const itemLimit = 200;
162
163         const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit });
164
165         if (itemsAvailable > itemLimit) {
166             items.push({
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 ***`,
170                 description: "",
171                 definition: "",
172                 ownerUuid: "",
173                 createdAt: "",
174                 modifiedByClientUuid: "",
175                 modifiedByUserUuid: "",
176                 modifiedAt: "",
177                 href: "",
178                 etag: ""
179             });
180         }
181
182         dispatch<any>(receiveTreePickerData<GroupContentsResource>({
183             id,
184             pickerId,
185             data: items.filter((item) => {
186                 if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
187                     return false;
188                 }
189
190                 if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
191                     return false;
192                 }
193
194                 return true;
195             }),
196             extractNodeData: item => (
197                 item.uuid === "more-items-available" ?
198                     {
199                         id: item.uuid,
200                         value: item,
201                         status: TreeNodeStatus.LOADED
202                     }
203                     : {
204                         id: item.uuid,
205                         value: item,
206                         status: item.kind === ResourceKind.PROJECT
207                             ? TreeNodeStatus.INITIAL
208                             : includeDirectories || includeFiles
209                                 ? TreeNodeStatus.INITIAL
210                                 : TreeNodeStatus.LOADED
211                     }),
212         }));
213     };
214
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 }));
218
219         const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
220         if (picker) {
221
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))
225                     .filter((file) => (
226                         (includeFiles) ||
227                         (includeDirectories && file.type === CollectionFileType.DIRECTORY)
228                     ));
229                 const tree = createCollectionFilesTree(files);
230                 const sorted = sortFilesTree(tree);
231                 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
232
233                 dispatch(
234                     treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
235                         id,
236                         pickerId,
237                         subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
238                     }));
239
240                 dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
241             }
242         }
243     };
244
245
246 export const initUserProject = (pickerId: string) =>
247     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
248         const uuid = getUserUuid(getState());
249         if (uuid) {
250             dispatch(receiveTreePickerData({
251                 id: '',
252                 pickerId,
253                 data: [{ uuid, name: 'Home Projects' }],
254                 extractNodeData: value => ({
255                     id: value.uuid,
256                     status: TreeNodeStatus.INITIAL,
257                     value,
258                 }),
259             }));
260         }
261     };
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());
265         if (uuid) {
266             dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
267         }
268     };
269
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({
274             id: '',
275             pickerId,
276             data: [{ uuid: SHARED_PROJECT_ID, name: SHARED_PROJECT_ID }],
277             extractNodeData: value => ({
278                 id: value.uuid,
279                 status: TreeNodeStatus.INITIAL,
280                 value,
281             }),
282         }));
283     };
284
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({
289             id: '',
290             pickerId,
291             data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
292             extractNodeData: value => ({
293                 id: value.uuid,
294                 status: TreeNodeStatus.INITIAL,
295                 value,
296             }),
297         }));
298     };
299
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({
304             id: '',
305             pickerId,
306             data: [{ uuid: PUBLIC_FAVORITES_PROJECT_ID, name: PUBLIC_FAVORITES_PROJECT_ID }],
307             extractNodeData: value => ({
308                 id: value.uuid,
309                 status: TreeNodeStatus.INITIAL,
310                 value,
311             }),
312         }));
313     };
314
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({
319             id: '',
320             pickerId,
321             data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
322             extractNodeData: value => ({
323                 id: value.uuid,
324                 status: TreeNodeStatus.INITIAL,
325                 value,
326             }),
327         }));
328     };
329
330
331 interface LoadFavoritesProjectParams {
332     pickerId: string;
333     includeCollections?: boolean;
334     includeDirectories?: boolean;
335     includeFiles?: boolean;
336     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
337 }
338
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());
344         if (uuid) {
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());
351
352             const { items } = await services.favoriteService.list(uuid, { filters }, options.showOnlyOwned);
353
354             dispatch<any>(receiveTreePickerData<GroupContentsResource>({
355                 id: 'Favorites',
356                 pickerId,
357                 data: items.filter((item) => {
358                     if (options.showOnlyWritable && (item as GroupResource).writableBy && (item as GroupResource).writableBy.indexOf(uuid) === -1) {
359                         return false;
360                     }
361
362                     if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
363                         return false;
364                     }
365
366                     return true;
367                 }),
368                 extractNodeData: item => ({
369                     id: item.uuid,
370                     value: item,
371                     status: item.kind === ResourceKind.PROJECT
372                         ? TreeNodeStatus.INITIAL
373                         : includeDirectories || includeFiles
374                             ? TreeNodeStatus.INITIAL
375                             : TreeNodeStatus.LOADED
376                 }),
377             }));
378         }
379     };
380
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`;
386
387         const filters = pipe(
388             (fb: FilterBuilder) => includeCollections
389                 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
390                 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
391             fb => fb
392                 .addEqual('link_class', LinkClass.STAR)
393                 .addEqual('owner_uuid', publicProjectUuid)
394                 .getFilters(),
395         )(new FilterBuilder());
396
397         const { items } = await services.linkService.list({ filters });
398
399         dispatch<any>(receiveTreePickerData<LinkResource>({
400             id: 'Public Favorites',
401             pickerId,
402             data: items.filter(item => {
403                 if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
404                     return false;
405                 }
406
407                 return true;
408             }),
409             extractNodeData: item => ({
410                 id: item.headUuid,
411                 value: item,
412                 status: item.headKind === ResourceKind.PROJECT
413                     ? TreeNodeStatus.INITIAL
414                     : includeDirectories || includeFiles
415                         ? TreeNodeStatus.INITIAL
416                         : TreeNodeStatus.LOADED
417             }),
418         }));
419     };
420
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({
424             id,
425             nodes: projects.map(project => initTreeNode({ id: project.uuid, value: project })),
426             pickerId,
427         }));
428
429         dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
430     };
431
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 }));
435
436
437         const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
438         const { items } = await services.projectService.list(buildParams(ownerUuid));
439
440         dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
441     };
442
443 export const loadFavoriteTreePickerProjects = (id: string) =>
444     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
445         const parentId = getUserUuid(getState()) || '';
446
447         if (id === '') {
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));
451         } else {
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));
455         }
456
457     };
458
459 export const loadPublicFavoriteTreePickerProjects = (id: string) =>
460     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
461         const parentId = getUserUuid(getState()) || '';
462
463         if (id === '') {
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));
467         } else {
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));
471         }
472
473     };
474
475 const buildParams = (ownerUuid: string) => {
476     return {
477         filters: new FilterBuilder()
478             .addEqual('owner_uuid', ownerUuid)
479             .getFilters(),
480         order: new OrderBuilder<ProjectResource>()
481             .addAsc('name')
482             .getOrder()
483     };
484 };