19783: All done
[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 { 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     includeFiles?: boolean;
46     includeFilterGroups?: boolean;
47     options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; };
48 }
49
50 export const treePickerSearchActions = unionize({
51     SET_TREE_PICKER_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(),
52     SET_TREE_PICKER_COLLECTION_FILTER: ofType<{ pickerId: string, collectionFilterValue: string }>(),
53     SET_TREE_PICKER_LOAD_PARAMS: ofType<{ pickerId: string, params: LoadProjectParams }>(),
54 });
55
56 export type TreePickerSearchAction = UnionOf<typeof treePickerSearchActions>;
57
58 export const getProjectsTreePickerIds = (pickerId: string) => ({
59     home: `${pickerId}_home`,
60     shared: `${pickerId}_shared`,
61     favorites: `${pickerId}_favorites`,
62     publicFavorites: `${pickerId}_publicFavorites`,
63     search: `${pickerId}_search`,
64 });
65
66 export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
67     pipe(
68         () => values(getProjectsTreePickerIds(pickerId)),
69
70         ids => ids
71             .map(id => getTreePicker<Value>(id)(state)),
72
73         trees => trees
74             .map(getNodeDescendants(''))
75             .reduce((allNodes, nodes) => allNodes.concat(nodes), []),
76
77         allNodes => allNodes
78             .reduce((map, node) =>
79                 filter(node)
80                     ? map.set(node.id, node)
81                     : map, new Map<string, TreeNode<Value>>())
82             .values(),
83
84         uniqueNodes => Array.from(uniqueNodes),
85     )();
86 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
87     getAllNodes<Value>(pickerId, node => node.selected)(state);
88
89 export const initProjectsTreePicker = (pickerId: string) =>
90     async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
91         const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
92         dispatch<any>(initUserProject(home));
93         dispatch<any>(initSharedProject(shared));
94         dispatch<any>(initFavoritesProject(favorites));
95         dispatch<any>(initPublicFavoritesProject(publicFavorites));
96         dispatch<any>(initSearchProject(search));
97     };
98
99 interface ReceiveTreePickerDataParams<T> {
100     data: T[];
101     extractNodeData: (value: T) => { id: string, value: T, status?: TreeNodeStatus };
102     id: string;
103     pickerId: string;
104 }
105
106 export const receiveTreePickerData = <T>(params: ReceiveTreePickerDataParams<T>) =>
107     (dispatch: Dispatch) => {
108         const { data, extractNodeData, id, pickerId, } = params;
109         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
110             id,
111             nodes: data.map(item => initTreeNode(extractNodeData(item))),
112             pickerId,
113         }));
114         dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
115     };
116
117 interface LoadProjectParamsWithId extends LoadProjectParams {
118     id: string;
119     pickerId: string;
120     loadShared?: boolean;
121     searchProjects?: boolean;
122 }
123
124 export const loadProject = (params: LoadProjectParamsWithId) =>
125     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
126         const { id, pickerId, includeCollections = false, includeFiles = false, includeFilterGroups = false, loadShared = false, options, searchProjects = false } = params;
127
128         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
129
130         let filterB = new FilterBuilder();
131
132         filterB = (includeCollections && !searchProjects)
133             ? filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
134             : filterB.addIsA('uuid', [ResourceKind.PROJECT]);
135
136         const state = getState();
137
138         if (state.treePickerSearch.collectionFilterValues[pickerId]) {
139             filterB = filterB.addILike('collections.name', state.treePickerSearch.collectionFilterValues[pickerId]);
140         } else {
141             filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
142         }
143
144         if (searchProjects && state.treePickerSearch.projectSearchValues[pickerId]) {
145             filterB = filterB.addILike('groups.name', state.treePickerSearch.projectSearchValues[pickerId]);
146         }
147
148         const filters = filterB.getFilters();
149
150         const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: 1000 });
151
152         if (itemsAvailable > 1000) {
153             items.push({
154                 uuid: "more-items-available",
155                 kind: ResourceKind.WORKFLOW,
156                 name: "*** Not all items were loaded (limit 1000 items) ***",
157                 description: "",
158                 definition: "",
159                 ownerUuid: "",
160                 createdAt: "",
161                 modifiedByClientUuid: "",
162                 modifiedByUserUuid: "",
163                 modifiedAt: "",
164                 href: "",
165                 etag: ""
166             });
167         }
168
169         dispatch<any>(receiveTreePickerData<GroupContentsResource>({
170             id,
171             pickerId,
172             data: items.filter((item) => {
173                 if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
174                     return false;
175                 }
176
177                 if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
178                     return false;
179                 }
180
181                 return true;
182             }),
183             extractNodeData: item => (
184                 item.uuid === "more-items-available" ?
185                     {
186                         id: item.uuid,
187                         value: item,
188                         status: TreeNodeStatus.LOADED
189                     }
190                     : {
191                         id: item.uuid,
192                         value: item,
193                         status: item.kind === ResourceKind.PROJECT
194                             ? TreeNodeStatus.INITIAL
195                             : includeFiles
196                                 ? TreeNodeStatus.INITIAL
197                                 : TreeNodeStatus.LOADED
198                     }),
199         }));
200     };
201
202 export const loadCollection = (id: string, pickerId: string) =>
203     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
204         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
205
206         const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
207         if (picker) {
208
209             const node = getNode(id)(picker);
210             if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
211                 const files = await services.collectionService.files(node.value.portableDataHash);
212                 const tree = createCollectionFilesTree(files);
213                 const sorted = sortFilesTree(tree);
214                 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
215
216                 dispatch(
217                     treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
218                         id,
219                         pickerId,
220                         subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
221                     }));
222
223                 dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
224             }
225         }
226     };
227
228
229 export const initUserProject = (pickerId: string) =>
230     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
231         const uuid = getUserUuid(getState());
232         if (uuid) {
233             dispatch(receiveTreePickerData({
234                 id: '',
235                 pickerId,
236                 data: [{ uuid, name: 'Home Projects' }],
237                 extractNodeData: value => ({
238                     id: value.uuid,
239                     status: TreeNodeStatus.INITIAL,
240                     value,
241                 }),
242             }));
243         }
244     };
245 export const loadUserProject = (pickerId: string, includeCollections = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
246     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
247         const uuid = getUserUuid(getState());
248         if (uuid) {
249             dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeFiles, options }));
250         }
251     };
252
253 export const SHARED_PROJECT_ID = 'Shared with me';
254 export const initSharedProject = (pickerId: string) =>
255     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
256         dispatch(receiveTreePickerData({
257             id: '',
258             pickerId,
259             data: [{ uuid: SHARED_PROJECT_ID, name: SHARED_PROJECT_ID }],
260             extractNodeData: value => ({
261                 id: value.uuid,
262                 status: TreeNodeStatus.INITIAL,
263                 value,
264             }),
265         }));
266     };
267
268 export const FAVORITES_PROJECT_ID = 'Favorites';
269 export const initFavoritesProject = (pickerId: string) =>
270     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
271         dispatch(receiveTreePickerData({
272             id: '',
273             pickerId,
274             data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
275             extractNodeData: value => ({
276                 id: value.uuid,
277                 status: TreeNodeStatus.INITIAL,
278                 value,
279             }),
280         }));
281     };
282
283 export const PUBLIC_FAVORITES_PROJECT_ID = 'Public Favorites';
284 export const initPublicFavoritesProject = (pickerId: string) =>
285     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
286         dispatch(receiveTreePickerData({
287             id: '',
288             pickerId,
289             data: [{ uuid: PUBLIC_FAVORITES_PROJECT_ID, name: PUBLIC_FAVORITES_PROJECT_ID }],
290             extractNodeData: value => ({
291                 id: value.uuid,
292                 status: TreeNodeStatus.INITIAL,
293                 value,
294             }),
295         }));
296     };
297
298 export const SEARCH_PROJECT_ID = 'Search all Projects';
299 export const initSearchProject = (pickerId: string) =>
300     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
301         dispatch(receiveTreePickerData({
302             id: '',
303             pickerId,
304             data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
305             extractNodeData: value => ({
306                 id: value.uuid,
307                 status: TreeNodeStatus.INITIAL,
308                 value,
309             }),
310         }));
311     };
312
313
314 interface LoadFavoritesProjectParams {
315     pickerId: string;
316     includeCollections?: boolean;
317     includeFiles?: boolean;
318     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
319 }
320
321 export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
322     options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) =>
323     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
324         const { pickerId, includeCollections = false, includeFiles = false } = params;
325         const uuid = getUserUuid(getState());
326         if (uuid) {
327             const filters = pipe(
328                 (fb: FilterBuilder) => includeCollections
329                     ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
330                     : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
331                 fb => fb.getFilters(),
332             )(new FilterBuilder());
333
334             const { items } = await services.favoriteService.list(uuid, { filters }, options.showOnlyOwned);
335
336             dispatch<any>(receiveTreePickerData<GroupContentsResource>({
337                 id: 'Favorites',
338                 pickerId,
339                 data: items.filter((item) => {
340                     if (options.showOnlyWritable && (item as GroupResource).writableBy && (item as GroupResource).writableBy.indexOf(uuid) === -1) {
341                         return false;
342                     }
343
344                     if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
345                         return false;
346                     }
347
348                     return true;
349                 }),
350                 extractNodeData: item => ({
351                     id: item.uuid,
352                     value: item,
353                     status: item.kind === ResourceKind.PROJECT
354                         ? TreeNodeStatus.INITIAL
355                         : includeFiles
356                             ? TreeNodeStatus.INITIAL
357                             : TreeNodeStatus.LOADED
358                 }),
359             }));
360         }
361     };
362
363 export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =>
364     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
365         const { pickerId, includeCollections = false, includeFiles = false } = params;
366         const uuidPrefix = getState().auth.config.uuidPrefix;
367         const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
368
369         const filters = pipe(
370             (fb: FilterBuilder) => includeCollections
371                 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
372                 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
373             fb => fb
374                 .addEqual('link_class', LinkClass.STAR)
375                 .addEqual('owner_uuid', publicProjectUuid)
376                 .getFilters(),
377         )(new FilterBuilder());
378
379         const { items } = await services.linkService.list({ filters });
380
381         dispatch<any>(receiveTreePickerData<LinkResource>({
382             id: 'Public Favorites',
383             pickerId,
384             data: items.filter(item => {
385                 if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
386                     return false;
387                 }
388
389                 return true;
390             }),
391             extractNodeData: item => ({
392                 id: item.headUuid,
393                 value: item,
394                 status: item.headKind === ResourceKind.PROJECT
395                     ? TreeNodeStatus.INITIAL
396                     : includeFiles
397                         ? TreeNodeStatus.INITIAL
398                         : TreeNodeStatus.LOADED
399             }),
400         }));
401     };
402
403 export const receiveTreePickerProjectsData = (id: string, projects: ProjectResource[], pickerId: string) =>
404     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
405         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
406             id,
407             nodes: projects.map(project => initTreeNode({ id: project.uuid, value: project })),
408             pickerId,
409         }));
410
411         dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
412     };
413
414 export const loadProjectTreePickerProjects = (id: string) =>
415     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
416         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PROJECTS }));
417
418
419         const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
420         const { items } = await services.projectService.list(buildParams(ownerUuid));
421
422         dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
423     };
424
425 export const loadFavoriteTreePickerProjects = (id: string) =>
426     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
427         const parentId = getUserUuid(getState()) || '';
428
429         if (id === '') {
430             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.FAVORITES }));
431             const { items } = await services.favoriteService.list(parentId);
432             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
433         } else {
434             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.FAVORITES }));
435             const { items } = await services.projectService.list(buildParams(id));
436             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.FAVORITES));
437         }
438
439     };
440
441 export const loadPublicFavoriteTreePickerProjects = (id: string) =>
442     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
443         const parentId = getUserUuid(getState()) || '';
444
445         if (id === '') {
446             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.PUBLIC_FAVORITES }));
447             const { items } = await services.favoriteService.list(parentId);
448             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.PUBLIC_FAVORITES));
449         } else {
450             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PUBLIC_FAVORITES }));
451             const { items } = await services.projectService.list(buildParams(id));
452             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PUBLIC_FAVORITES));
453         }
454
455     };
456
457 const buildParams = (ownerUuid: string) => {
458     return {
459         filters: new FilterBuilder()
460             .addEqual('owner_uuid', ownerUuid)
461             .getFilters(),
462         order: new OrderBuilder<ProjectResource>()
463             .addAsc('name')
464             .getOrder()
465     };
466 };