20251: Fix flaky collection file browser by using race-free state update callback
[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.addFullTextSearch(state.treePickerSearch.collectionFilterValues[pickerId], 'collections');
140         } else {
141             filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
142         }
143
144         if (searchProjects && state.treePickerSearch.projectSearchValues[pickerId]) {
145             filterB = filterB.addFullTextSearch(state.treePickerSearch.projectSearchValues[pickerId], 'groups');
146         }
147
148         const filters = filterB.getFilters();
149
150         const itemLimit = 200;
151
152         const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit });
153
154         if (itemsAvailable > itemLimit) {
155             items.push({
156                 uuid: "more-items-available",
157                 kind: ResourceKind.WORKFLOW,
158                 name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`,
159                 description: "",
160                 definition: "",
161                 ownerUuid: "",
162                 createdAt: "",
163                 modifiedByClientUuid: "",
164                 modifiedByUserUuid: "",
165                 modifiedAt: "",
166                 href: "",
167                 etag: ""
168             });
169         }
170
171         dispatch<any>(receiveTreePickerData<GroupContentsResource>({
172             id,
173             pickerId,
174             data: items.filter((item) => {
175                 if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
176                     return false;
177                 }
178
179                 if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
180                     return false;
181                 }
182
183                 return true;
184             }),
185             extractNodeData: item => (
186                 item.uuid === "more-items-available" ?
187                     {
188                         id: item.uuid,
189                         value: item,
190                         status: TreeNodeStatus.LOADED
191                     }
192                     : {
193                         id: item.uuid,
194                         value: item,
195                         status: item.kind === ResourceKind.PROJECT
196                             ? TreeNodeStatus.INITIAL
197                             : includeFiles
198                                 ? TreeNodeStatus.INITIAL
199                                 : TreeNodeStatus.LOADED
200                     }),
201         }));
202     };
203
204 export const loadCollection = (id: string, pickerId: string) =>
205     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
206         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
207
208         const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
209         if (picker) {
210
211             const node = getNode(id)(picker);
212             if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
213                 const files = await services.collectionService.files(node.value.uuid);
214                 const tree = createCollectionFilesTree(files);
215                 const sorted = sortFilesTree(tree);
216                 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
217
218                 dispatch(
219                     treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
220                         id,
221                         pickerId,
222                         subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
223                     }));
224
225                 dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
226             }
227         }
228     };
229
230
231 export const initUserProject = (pickerId: string) =>
232     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
233         const uuid = getUserUuid(getState());
234         if (uuid) {
235             dispatch(receiveTreePickerData({
236                 id: '',
237                 pickerId,
238                 data: [{ uuid, name: 'Home Projects' }],
239                 extractNodeData: value => ({
240                     id: value.uuid,
241                     status: TreeNodeStatus.INITIAL,
242                     value,
243                 }),
244             }));
245         }
246     };
247 export const loadUserProject = (pickerId: string, includeCollections = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
248     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
249         const uuid = getUserUuid(getState());
250         if (uuid) {
251             dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeFiles, options }));
252         }
253     };
254
255 export const SHARED_PROJECT_ID = 'Shared with me';
256 export const initSharedProject = (pickerId: string) =>
257     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
258         dispatch(receiveTreePickerData({
259             id: '',
260             pickerId,
261             data: [{ uuid: SHARED_PROJECT_ID, name: SHARED_PROJECT_ID }],
262             extractNodeData: value => ({
263                 id: value.uuid,
264                 status: TreeNodeStatus.INITIAL,
265                 value,
266             }),
267         }));
268     };
269
270 export const FAVORITES_PROJECT_ID = 'Favorites';
271 export const initFavoritesProject = (pickerId: string) =>
272     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
273         dispatch(receiveTreePickerData({
274             id: '',
275             pickerId,
276             data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
277             extractNodeData: value => ({
278                 id: value.uuid,
279                 status: TreeNodeStatus.INITIAL,
280                 value,
281             }),
282         }));
283     };
284
285 export const PUBLIC_FAVORITES_PROJECT_ID = 'Public Favorites';
286 export const initPublicFavoritesProject = (pickerId: string) =>
287     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
288         dispatch(receiveTreePickerData({
289             id: '',
290             pickerId,
291             data: [{ uuid: PUBLIC_FAVORITES_PROJECT_ID, name: PUBLIC_FAVORITES_PROJECT_ID }],
292             extractNodeData: value => ({
293                 id: value.uuid,
294                 status: TreeNodeStatus.INITIAL,
295                 value,
296             }),
297         }));
298     };
299
300 export const SEARCH_PROJECT_ID = 'Search all Projects';
301 export const initSearchProject = (pickerId: string) =>
302     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
303         dispatch(receiveTreePickerData({
304             id: '',
305             pickerId,
306             data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
307             extractNodeData: value => ({
308                 id: value.uuid,
309                 status: TreeNodeStatus.INITIAL,
310                 value,
311             }),
312         }));
313     };
314
315
316 interface LoadFavoritesProjectParams {
317     pickerId: string;
318     includeCollections?: boolean;
319     includeFiles?: boolean;
320     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
321 }
322
323 export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
324     options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) =>
325     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
326         const { pickerId, includeCollections = false, includeFiles = false } = params;
327         const uuid = getUserUuid(getState());
328         if (uuid) {
329             const filters = pipe(
330                 (fb: FilterBuilder) => includeCollections
331                     ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
332                     : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
333                 fb => fb.getFilters(),
334             )(new FilterBuilder());
335
336             const { items } = await services.favoriteService.list(uuid, { filters }, options.showOnlyOwned);
337
338             dispatch<any>(receiveTreePickerData<GroupContentsResource>({
339                 id: 'Favorites',
340                 pickerId,
341                 data: items.filter((item) => {
342                     if (options.showOnlyWritable && (item as GroupResource).writableBy && (item as GroupResource).writableBy.indexOf(uuid) === -1) {
343                         return false;
344                     }
345
346                     if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
347                         return false;
348                     }
349
350                     return true;
351                 }),
352                 extractNodeData: item => ({
353                     id: item.uuid,
354                     value: item,
355                     status: item.kind === ResourceKind.PROJECT
356                         ? TreeNodeStatus.INITIAL
357                         : includeFiles
358                             ? TreeNodeStatus.INITIAL
359                             : TreeNodeStatus.LOADED
360                 }),
361             }));
362         }
363     };
364
365 export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =>
366     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
367         const { pickerId, includeCollections = false, includeFiles = false } = params;
368         const uuidPrefix = getState().auth.config.uuidPrefix;
369         const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
370
371         const filters = pipe(
372             (fb: FilterBuilder) => includeCollections
373                 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
374                 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
375             fb => fb
376                 .addEqual('link_class', LinkClass.STAR)
377                 .addEqual('owner_uuid', publicProjectUuid)
378                 .getFilters(),
379         )(new FilterBuilder());
380
381         const { items } = await services.linkService.list({ filters });
382
383         dispatch<any>(receiveTreePickerData<LinkResource>({
384             id: 'Public Favorites',
385             pickerId,
386             data: items.filter(item => {
387                 if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
388                     return false;
389                 }
390
391                 return true;
392             }),
393             extractNodeData: item => ({
394                 id: item.headUuid,
395                 value: item,
396                 status: item.headKind === ResourceKind.PROJECT
397                     ? TreeNodeStatus.INITIAL
398                     : includeFiles
399                         ? TreeNodeStatus.INITIAL
400                         : TreeNodeStatus.LOADED
401             }),
402         }));
403     };
404
405 export const receiveTreePickerProjectsData = (id: string, projects: ProjectResource[], pickerId: string) =>
406     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
407         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
408             id,
409             nodes: projects.map(project => initTreeNode({ id: project.uuid, value: project })),
410             pickerId,
411         }));
412
413         dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
414     };
415
416 export const loadProjectTreePickerProjects = (id: string) =>
417     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
418         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PROJECTS }));
419
420
421         const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
422         const { items } = await services.projectService.list(buildParams(ownerUuid));
423
424         dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
425     };
426
427 export const loadFavoriteTreePickerProjects = (id: string) =>
428     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
429         const parentId = getUserUuid(getState()) || '';
430
431         if (id === '') {
432             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.FAVORITES }));
433             const { items } = await services.favoriteService.list(parentId);
434             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
435         } else {
436             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.FAVORITES }));
437             const { items } = await services.projectService.list(buildParams(id));
438             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.FAVORITES));
439         }
440
441     };
442
443 export const loadPublicFavoriteTreePickerProjects = (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.PUBLIC_FAVORITES }));
449             const { items } = await services.favoriteService.list(parentId);
450             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.PUBLIC_FAVORITES));
451         } else {
452             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PUBLIC_FAVORITES }));
453             const { items } = await services.projectService.list(buildParams(id));
454             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PUBLIC_FAVORITES));
455         }
456
457     };
458
459 const buildParams = (ownerUuid: string) => {
460     return {
461         filters: new FilterBuilder()
462             .addEqual('owner_uuid', ownerUuid)
463             .getFilters(),
464         order: new OrderBuilder<ProjectResource>()
465             .addAsc('name')
466             .getOrder()
467     };
468 };