21838: Remove stray console logs & unused imports
[arvados.git] / services / workbench2 / 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, setNode, createTree, getNodeDescendantsIds } from 'models/tree';
7 import { CollectionFileType, createCollectionFilesTree, getCollectionResourceCollectionUuid } 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, ResourceObjectType, extractUuidObjectType, COLLECTION_PDH_REGEX } from 'models/resource';
15 import { GroupContentsResource } from 'services/groups-service/groups-service';
16 import { getTreePicker, TreePicker, TreeItemWeight, TreeItemWithWeight } 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 import { CollectionResource } from "models/collection";
26 import { getResource } from "store/resources/resources";
27 import { updateResources } from "store/resources/resources-actions";
28 import { SnackbarKind, snackbarActions } from "store/snackbar/snackbar-actions";
29 import { call, put, takeEvery, takeLatest, getContext, select, all } from "redux-saga/effects";
30
31 export const treePickerActions = unionize({
32     LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
33     LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array<TreeNode<any>>, pickerId: string }>(),
34     APPEND_TREE_PICKER_NODE_SUBTREE: ofType<{ id: string, subtree: Tree<any>, pickerId: string }>(),
35     TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string, pickerId: string }>(),
36     EXPAND_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
37     EXPAND_TREE_PICKER_NODE_ANCESTORS: ofType<{ id: string, pickerId: string }>(),
38     ACTIVATE_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string, relatedTreePickers?: string[] }>(),
39     DEACTIVATE_TREE_PICKER_NODE: ofType<{ pickerId: string }>(),
40     TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string, cascade: boolean }>(),
41     SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(),
42     DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(),
43     EXPAND_TREE_PICKER_NODES: ofType<{ ids: string[], pickerId: string }>(),
44     RESET_TREE_PICKER: ofType<{ pickerId: string }>()
45 });
46
47 export type TreePickerAction = UnionOf<typeof treePickerActions>;
48
49 export interface LoadProjectParams {
50     includeCollections?: boolean;
51     includeDirectories?: boolean;
52     includeFiles?: boolean;
53     includeFilterGroups?: boolean;
54     options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; };
55 }
56
57 export const treePickerSearchActions = unionize({
58     SET_TREE_PICKER_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(),
59     SET_TREE_PICKER_COLLECTION_FILTER: ofType<{ pickerId: string, collectionFilterValue: string }>(),
60     SET_TREE_PICKER_LOAD_PARAMS: ofType<{ pickerId: string, params: LoadProjectParams }>(),
61 });
62
63 export type TreePickerSearchAction = UnionOf<typeof treePickerSearchActions>;
64
65 export const treePickerSearchSagas = unionize({
66     SET_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(),
67     SET_COLLECTION_FILTER: ofType<{ pickerMainId: string, collectionFilterValue: string }>(),
68     APPLY_COLLECTION_FILTER: ofType<{ pickerId: string }>(),
69     LOAD_PROJECT: ofType<LoadProjectParamsWithId>(),
70     LOAD_SEARCH: ofType<LoadProjectParamsWithId>(),
71     LOAD_FAVORITES_PROJECT: ofType<LoadFavoritesProjectParams>(),
72     LOAD_PUBLIC_FAVORITES_PROJECT: ofType<LoadFavoritesProjectParams>(),
73     REFRESH_TREE_PICKER: ofType<{ pickerId: string }>(),
74 });
75
76 export function* setTreePickerProjectSearchWatcher() {
77     // Race conditions are handled in loadSearchWatcher so takeEvery is used here to avoid confusion
78     yield takeEvery(treePickerSearchSagas.tags.SET_PROJECT_SEARCH, setTreePickerProjectSearchSaga);
79 }
80
81 function* setTreePickerProjectSearchSaga({type, payload}: {
82     type: typeof treePickerSearchSagas.tags.SET_PROJECT_SEARCH,
83     payload: typeof treePickerSearchSagas._Record.SET_PROJECT_SEARCH,
84 }) {
85     try {
86         const { pickerId , projectSearchValue } = payload;
87         const state: RootState = yield select();
88         const searchChanged = state.treePickerSearch.projectSearchValues[pickerId] !== projectSearchValue;
89
90         if (searchChanged) {
91             yield put(treePickerSearchActions.SET_TREE_PICKER_PROJECT_SEARCH(payload));
92             const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(state.treePicker);
93             if (picker) {
94                 const loadParams = state.treePickerSearch.loadProjectParams[pickerId];
95                 // Put is non-blocking so race-condition prevention is handled by the loadSearchWatcher
96                 yield put(treePickerSearchSagas.LOAD_SEARCH({
97                     ...loadParams,
98                     id: SEARCH_PROJECT_ID,
99                     pickerId,
100                 }));
101             }
102         }
103     } catch (e) {
104         yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to search`, kind: SnackbarKind.ERROR }));
105     }
106 }
107
108 /**
109  * Race-free collection filter saga as long as it's invoked through SET_COLLECTION_FILTER
110  */
111 export function* setTreePickerCollectionFilterWatcher() {
112     yield takeLatest(treePickerSearchSagas.tags.SET_COLLECTION_FILTER, setTreePickerCollectionFilterSaga);
113 }
114
115 function* setTreePickerCollectionFilterSaga({type, payload}: {
116     type: typeof treePickerSearchSagas.tags.SET_COLLECTION_FILTER,
117     payload: typeof treePickerSearchSagas._Record.SET_COLLECTION_FILTER,
118 }) {
119     try {
120         const state: RootState = yield select();
121         const { pickerMainId , collectionFilterValue } = payload;
122         const pickerRootItemIds = Object.values(getProjectsTreePickerIds(pickerMainId));
123
124         const changedRootItemIds = pickerRootItemIds.filter((pickerRootId) =>
125             state.treePickerSearch.collectionFilterValues[pickerRootId] !== collectionFilterValue
126         );
127
128         yield all(pickerRootItemIds.map(pickerId =>
129             put(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({
130                 pickerId,
131                 collectionFilterValue,
132             }))
133         ));
134
135         yield all(changedRootItemIds.map(pickerId =>
136             call(applyCollectionFilterSaga, {
137                 type: treePickerSearchSagas.tags.APPLY_COLLECTION_FILTER,
138                 payload: { pickerId }
139             })
140         ));
141     } catch (e) {
142         yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to search`, kind: SnackbarKind.ERROR }));
143     } finally {
144         // Optionally handle cleanup when task cancelled
145         // if (yield cancelled()) {}
146     }
147 }
148
149 /**
150  * Only meant to be called synchronously via call from other sagas that implement takeLatest
151  */
152 function* applyCollectionFilterSaga({type, payload}: {
153     type: typeof treePickerSearchSagas.tags.APPLY_COLLECTION_FILTER,
154     payload: typeof treePickerSearchSagas._Record.APPLY_COLLECTION_FILTER,
155 }) {
156     try {
157         const state: RootState = yield select();
158         const { pickerId } = payload;
159         if (state.treePickerSearch.projectSearchValues[pickerId] !== "") {
160             yield call(refreshTreePickerSaga, {
161                 type: treePickerSearchSagas.tags.REFRESH_TREE_PICKER,
162                 payload: { pickerId }
163             });
164         } else {
165             const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(state.treePicker);
166             if (picker) {
167                 const loadParams = state.treePickerSearch.loadProjectParams[pickerId];
168                 yield call(loadProjectSaga, {
169                     type: treePickerSearchSagas.tags.LOAD_PROJECT,
170                     payload: {
171                         ...loadParams,
172                         id: SEARCH_PROJECT_ID,
173                         pickerId,
174                 }});
175             }
176         }
177     } catch (e) {
178         yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to search`, kind: SnackbarKind.ERROR }));
179     }
180 }
181
182 export const getProjectsTreePickerIds = (pickerId: string) => ({
183     home: `${pickerId}_home`,
184     shared: `${pickerId}_shared`,
185     favorites: `${pickerId}_favorites`,
186     publicFavorites: `${pickerId}_publicFavorites`,
187     search: `${pickerId}_search`,
188 });
189
190 export const SEARCH_PROJECT_ID_PREFIX = "search-";
191
192 export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
193     pipe(
194         () => values(getProjectsTreePickerIds(pickerId)),
195
196         ids => ids
197             .map(id => getTreePicker<Value>(id)(state)),
198
199         trees => trees
200             .map(getNodeDescendants(''))
201             .reduce((allNodes, nodes) => allNodes.concat(nodes), []),
202
203         allNodes => allNodes
204             .reduce((map, node) =>
205                 filter(node)
206                     ? map.set(node.id, node)
207                     : map, new Map<string, TreeNode<Value>>())
208             .values(),
209
210         uniqueNodes => Array.from(uniqueNodes),
211     )();
212 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
213     getAllNodes<Value>(pickerId, node => node.selected)(state);
214
215 interface TreePickerPreloadParams {
216     selectedItemUuids: string[];
217     includeDirectories: boolean;
218     includeFiles: boolean;
219     multi: boolean;
220 }
221
222 export const initProjectsTreePicker = (pickerId: string, preloadParams?: TreePickerPreloadParams) =>
223     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
224         const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
225         dispatch<any>(initUserProject(home));
226         dispatch<any>(initSharedProject(shared));
227         dispatch<any>(initFavoritesProject(favorites));
228         dispatch<any>(initPublicFavoritesProject(publicFavorites));
229         dispatch<any>(initSearchProject(search));
230
231         if (preloadParams && preloadParams.selectedItemUuids.length) {
232             await dispatch<any>(loadInitialValue(
233                 preloadParams.selectedItemUuids,
234                 pickerId,
235                 preloadParams.includeDirectories,
236                 preloadParams.includeFiles,
237                 preloadParams.multi
238             ));
239         }
240     };
241
242 interface ReceiveTreePickerDataParams<T> {
243     data: T[];
244     extractNodeData: (value: T) => { id: string, value: T, status?: TreeNodeStatus };
245     id: string;
246     pickerId: string;
247 }
248
249 export const receiveTreePickerData = <T>(params: ReceiveTreePickerDataParams<T>) =>
250     (dispatch: Dispatch) => {
251         const { data, extractNodeData, id, pickerId, } = params;
252         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
253             id,
254             nodes: data.map(item => initTreeNode(extractNodeData(item))),
255             pickerId,
256         }));
257         dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
258     };
259
260 export const extractGroupContentsNodeData = (expandableCollections: boolean) => (item: GroupContentsResource & TreeItemWithWeight) => {
261     if (item.uuid === "more-items-available") {
262         return {
263             id: item.uuid,
264             value: item,
265             status: TreeNodeStatus.LOADED
266         }
267     } else if (item.weight === TreeItemWeight.LIGHT) {
268         return {
269             id: SEARCH_PROJECT_ID_PREFIX+item.uuid,
270             value: item,
271             status: item.kind === ResourceKind.PROJECT
272                   ? TreeNodeStatus.INITIAL
273                   : item.kind === ResourceKind.COLLECTION && expandableCollections
274                   ? TreeNodeStatus.INITIAL
275                   : TreeNodeStatus.LOADED
276         };
277     } else {
278         return { id: item.uuid,
279                  value: item,
280                  status: item.kind === ResourceKind.PROJECT
281                        ? TreeNodeStatus.INITIAL
282                        : item.kind === ResourceKind.COLLECTION && expandableCollections
283                        ? TreeNodeStatus.INITIAL
284                        : TreeNodeStatus.LOADED
285         };
286     }
287 };
288
289 interface LoadProjectParamsWithId extends LoadProjectParams {
290     id: string;
291     pickerId: string;
292     loadShared?: boolean;
293 }
294
295 /**
296  * Kicks off a picker search load that allows paralell runs
297  * Used for expanding nodes
298  */
299 export const loadProject = (params: LoadProjectParamsWithId) => (treePickerSearchSagas.LOAD_PROJECT(params));
300 export function* loadProjectWatcher() {
301     yield takeEvery(treePickerSearchSagas.tags.LOAD_PROJECT, loadProjectSaga);
302 }
303
304 /**
305  * Asynchronously kicks off a race-free picker search load - does not block when used this way
306  */
307 export const loadSearch = (params: LoadProjectParamsWithId) => (treePickerSearchSagas.LOAD_SEARCH(params));
308 export function* loadSearchWatcher() {
309     yield takeLatest(treePickerSearchSagas.tags.LOAD_SEARCH, loadProjectSaga);
310 }
311
312 /**
313  * loadProjectSaga is used to load or refresh a project node in a tree picker
314  * Errors are caught and a toast is shown if the project fails to load
315  * Blocks when called directly with call(), can be composed into race-free groups
316  */
317 function* loadProjectSaga({type, payload}: {
318     type: typeof treePickerSearchSagas.tags.LOAD_PROJECT,
319     payload: typeof treePickerSearchSagas._Record.LOAD_PROJECT,
320 }) {
321     try {
322         const services: ServiceRepository = yield getContext("services");
323         const state: RootState = yield select();
324
325         const {
326             id,
327             pickerId,
328             includeCollections = false,
329             includeDirectories = false,
330             includeFiles = false,
331             includeFilterGroups = false,
332             loadShared = false,
333             options,
334         } = payload;
335
336         const searching = (id === SEARCH_PROJECT_ID);
337         const collectionFilter = state.treePickerSearch.collectionFilterValues[pickerId];
338         const projectFilter = state.treePickerSearch.projectSearchValues[pickerId];
339
340         let filterB = new FilterBuilder();
341
342         let includeOwners: string|undefined = undefined;
343
344         if (id.startsWith(SEARCH_PROJECT_ID_PREFIX)) {
345             return;
346         }
347
348         if (searching) {
349             // opening top level search
350             if (projectFilter) {
351                 includeOwners = "owner_uuid";
352                 filterB = filterB.addIsA('uuid', [ResourceKind.PROJECT]);
353
354                 const objtype = extractUuidObjectType(projectFilter);
355                 if (objtype === ResourceObjectType.GROUP || objtype === ResourceObjectType.USER) {
356                         filterB = filterB.addEqual('uuid', projectFilter);
357                 }
358                 else {
359                     filterB = filterB.addFullTextSearch(projectFilter, 'groups');
360                 }
361
362             } else if (collectionFilter) {
363                 includeOwners = "owner_uuid";
364                 filterB = filterB.addIsA('uuid', [ResourceKind.COLLECTION]);
365
366                 const objtype = extractUuidObjectType(collectionFilter);
367                 if (objtype === ResourceObjectType.COLLECTION) {
368                     filterB = filterB.addEqual('uuid', collectionFilter);
369                 } else if (COLLECTION_PDH_REGEX.exec(collectionFilter)) {
370                     filterB = filterB.addEqual('portable_data_hash', collectionFilter);
371                 } else {
372                     filterB = filterB.addFullTextSearch(collectionFilter, 'collections');
373                 }
374             } else {
375                 return;
376             }
377         } else {
378             // opening a folder below the top level
379             if (includeCollections) {
380                 filterB = filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION]);
381             } else {
382                 filterB = filterB.addIsA('uuid', [ResourceKind.PROJECT]);
383             }
384
385             if (projectFilter) {
386                 filterB = filterB.addFullTextSearch(projectFilter, 'groups');
387             }
388             if (collectionFilter) {
389                 filterB = filterB.addFullTextSearch(collectionFilter, 'collections');
390             }
391         }
392
393         filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
394
395         const globalSearch = loadShared || id === SEARCH_PROJECT_ID;
396
397         const filters = filterB.getFilters();
398
399         // Must be under 1000
400         const itemLimit = 200;
401
402         yield put(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
403
404         let { items, included } = yield call(
405             {context: services.groupsService, fn: services.groupsService.contents},
406             globalSearch ? '' : id,
407             {
408                 filters,
409                 excludeHomeProject: loadShared || undefined,
410                 limit: itemLimit+1,
411                 count: "none",
412                 include: includeOwners,
413             }
414         );
415
416         if (!included) {
417             includeOwners = undefined;
418         }
419
420         //let rootItems: GroupContentsResource[] | GroupContentsIncludedResource[] = items;
421         let rootItems: any[] = items;
422
423         const seen = {};
424
425         if (includeOwners && included) {
426             included = included.filter(item => {
427                 if (seen.hasOwnProperty(item.uuid)) {
428                     return false;
429                 } else {
430                     seen[item.uuid] = item;
431                     return true;
432                 }
433             });
434             yield put(updateResources(included));
435
436             rootItems = included;
437         }
438
439         items = items.filter(item => {
440             if (seen.hasOwnProperty(item.uuid)) {
441                 return false;
442             } else {
443                 seen[item.uuid] = item;
444                 if (!seen[item.ownerUuid] && includeOwners) {
445                     rootItems.push(item);
446                 }
447                 return true;
448             }
449         });
450         yield put(updateResources(items));
451
452         if (items.length > itemLimit) {
453             rootItems.push({
454                 uuid: "more-items-available-"+id,
455                 kind: ResourceKind.WORKFLOW,
456                 name: `*** Not all items listed, reduce item count with search or filter ***`,
457                 description: "",
458                 definition: "",
459                 ownerUuid: "",
460                 createdAt: "",
461                 modifiedByUserUuid: "",
462                 modifiedAt: "",
463                 href: "",
464                 etag: ""
465             });
466         }
467
468         yield put(receiveTreePickerData<GroupContentsResource>({
469             id,
470             pickerId,
471             data: rootItems.filter(item => {
472                 if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
473                     return false;
474                 }
475
476                 if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
477                     return false;
478                 }
479                 return true;
480             }).map(item => {
481                 if (extractUuidObjectType(item.uuid) === ResourceObjectType.USER) {
482                     return {...item,
483                             uuid: item.uuid,
484                             name: item['fullName'] + " Home Project",
485                             weight: includeOwners ? TreeItemWeight.LIGHT : TreeItemWeight.NORMAL,
486                             kind: ResourceKind.USER,
487                     }
488                 }
489                 return {...item,
490                         uuid: item.uuid,
491                         weight: includeOwners ? TreeItemWeight.LIGHT : TreeItemWeight.NORMAL,};
492
493             }),
494             extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
495         }));
496
497         if (includeOwners) {
498             // Searching, we already have the
499             // contents to put in the owner projects so load it up.
500             const projects = {};
501             items.forEach(item => {
502                 if (!projects.hasOwnProperty(item.ownerUuid)) {
503                     projects[item.ownerUuid] = [];
504                 }
505                 projects[item.ownerUuid].push({...item, weight: TreeItemWeight.DARK});
506             });
507             for (const prj in projects) {
508                 yield put(receiveTreePickerData<GroupContentsResource>({
509                     id: SEARCH_PROJECT_ID_PREFIX+prj,
510                     pickerId,
511                     data: projects[prj],
512                     extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
513                 }));
514             }
515         }
516     } catch(e) {
517         console.error("Failed to load project into tree picker:", e);;
518         yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load project`, kind: SnackbarKind.ERROR }));
519     } finally {
520         // Optionally handle cleanup when task cancelled
521         // if (yield cancelled()) {}
522     }
523 };
524
525 export const refreshTreePicker = (params: typeof treePickerSearchSagas._Record.REFRESH_TREE_PICKER) => (treePickerSearchSagas.REFRESH_TREE_PICKER(params));
526
527 export function* refreshTreePickerWatcher() {
528     yield takeEvery(treePickerSearchSagas.tags.REFRESH_TREE_PICKER, refreshTreePickerSaga);
529 }
530
531 /**
532  * Refreshes a single tree picker subtree
533  */
534 function* refreshTreePickerSaga({type, payload}: {
535     type: typeof treePickerSearchSagas.tags.REFRESH_TREE_PICKER,
536     payload: typeof treePickerSearchSagas._Record.REFRESH_TREE_PICKER,
537 }) {
538     try {
539         const state: RootState = yield select();
540         const { pickerId } = payload;
541
542         const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(state.treePicker);
543         if (picker) {
544             const loadParams = state.treePickerSearch.loadProjectParams[pickerId];
545             yield all((getNodeDescendantsIds('')(picker)
546                 .reduce((acc, id) => {
547                     const node = getNode(id)(picker);
548                     if (node && node.status !== TreeNodeStatus.INITIAL) {
549                         if (node.id.substring(6, 11) === 'tpzed' || node.id.substring(6, 11) === 'j7d0g') {
550                             return acc.concat(call(loadProjectSaga, {
551                                 type: treePickerSearchSagas.tags.LOAD_PROJECT,
552                                 payload: {
553                                     ...loadParams,
554                                     id: node.id,
555                                     pickerId,
556                             }}));
557                         }
558                         if (node.id === SHARED_PROJECT_ID) {
559                             return acc.concat(call(loadProjectSaga, {
560                                 type: treePickerSearchSagas.tags.LOAD_PROJECT,
561                                 payload: {
562                                     ...loadParams,
563                                     id: node.id,
564                                     pickerId,
565                                     loadShared: true
566                             }}));
567                         }
568                         if (node.id === SEARCH_PROJECT_ID) {
569                             return acc.concat(call(loadProjectSaga, {
570                                 type: treePickerSearchSagas.tags.LOAD_PROJECT,
571                                 payload: {
572                                     ...loadParams,
573                                     id: node.id,
574                                     pickerId,
575                             }}));
576                         }
577                         if (node.id === FAVORITES_PROJECT_ID) {
578                             return acc.concat(call(loadFavoritesProjectSaga, {
579                                 type: treePickerSearchSagas.tags.LOAD_FAVORITES_PROJECT,
580                                 payload: {
581                                     ...loadParams,
582                                     pickerId,
583                             }}));
584                         }
585                         if (node.id === PUBLIC_FAVORITES_PROJECT_ID) {
586                             return acc.concat(call(loadPublicFavoritesProjectSaga, {
587                                 type: treePickerSearchSagas.tags.LOAD_PUBLIC_FAVORITES_PROJECT,
588                                 payload: {
589                                     ...loadParams,
590                                     pickerId,
591                             }}));
592                         }
593                     }
594                     return acc;
595                 }, [] as Object[])));
596         }
597     } catch (e) {
598         yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to search`, kind: SnackbarKind.ERROR }));
599     }
600 }
601
602 export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) =>
603     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
604         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
605
606         const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
607         if (picker) {
608
609             const node = getNode(id)(picker);
610             if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
611                 const files = (await services.collectionService.files(node.value.uuid))
612                     .filter((file) => (
613                         (includeFiles) ||
614                         (includeDirectories && file.type === CollectionFileType.DIRECTORY)
615                     ));
616                 const tree = createCollectionFilesTree(files);
617                 const sorted = sortFilesTree(tree);
618                 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
619
620                 // await tree modifications so that consumers can guarantee node presence
621                 await dispatch(
622                     treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
623                         id,
624                         pickerId,
625                         subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
626                     }));
627
628                 // Expand collection root node
629                 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
630             }
631         }
632     };
633
634 export const HOME_PROJECT_ID = 'Home Projects';
635 export const initUserProject = (pickerId: string) =>
636     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
637         const uuid = getUserUuid(getState());
638         if (uuid) {
639             dispatch(receiveTreePickerData({
640                 id: '',
641                 pickerId,
642                 data: [{ uuid, name: HOME_PROJECT_ID }],
643                 extractNodeData: value => ({
644                     id: value.uuid,
645                     status: TreeNodeStatus.INITIAL,
646                     value,
647                 }),
648             }));
649         }
650     };
651 export const loadUserProject = (pickerId: string, includeCollections = false, includeDirectories = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
652     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
653         const uuid = getUserUuid(getState());
654         if (uuid) {
655             dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
656         }
657     };
658
659 export const SHARED_PROJECT_ID = 'Shared with me';
660 export const initSharedProject = (pickerId: string) =>
661     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
662         dispatch(receiveTreePickerData({
663             id: '',
664             pickerId,
665             data: [{ uuid: SHARED_PROJECT_ID, name: SHARED_PROJECT_ID }],
666             extractNodeData: value => ({
667                 id: value.uuid,
668                 status: TreeNodeStatus.INITIAL,
669                 value,
670             }),
671         }));
672     };
673
674 type PickerItemPreloadData = {
675     itemId: string;
676     mainItemUuid: string;
677     ancestors: (GroupResource | CollectionResource)[];
678     isHomeProjectItem: boolean;
679 }
680
681 type PickerTreePreloadData = {
682     tree: Tree<GroupResource | CollectionResource>;
683     pickerTreeId: string;
684     pickerTreeRootUuid: string;
685 };
686
687 export const loadInitialValue = (pickerItemIds: string[], pickerId: string, includeDirectories: boolean, includeFiles: boolean, multi: boolean,) =>
688     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
689         const homeUuid = getUserUuid(getState());
690
691         // Request ancestor trees in paralell and save home project status
692         const pickerItemsData: PickerItemPreloadData[] = await Promise.allSettled(pickerItemIds.map(async itemId => {
693             const mainItemUuid = itemId.includes('/') ? itemId.split('/')[0] : itemId;
694
695             const ancestors = (await services.ancestorsService.ancestors(mainItemUuid, ''))
696             .filter(item =>
697                 item.kind === ResourceKind.GROUP ||
698                 item.kind === ResourceKind.COLLECTION
699             ) as (GroupResource | CollectionResource)[];
700
701             if (ancestors.length === 0) {
702                 return Promise.reject({item: itemId});
703             }
704
705             const isHomeProjectItem = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid));
706
707             return {
708                 itemId,
709                 mainItemUuid,
710                 ancestors,
711                 isHomeProjectItem,
712             };
713         })).then((res) => {
714             // Show toast if any selections failed to restore
715             const rejectedPromises = res.filter((promiseResult): promiseResult is PromiseRejectedResult => (promiseResult.status === 'rejected'));
716             if (rejectedPromises.length) {
717                 rejectedPromises.forEach(item => {
718                     console.error("The following item failed to load into the tree picker", item.reason);
719                 });
720                 dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Some selections failed to load and were removed. See console for details.`, kind: SnackbarKind.ERROR }));
721             }
722             // Filter out any failed promises and map to resulting preload data with ancestors
723             return res.filter((promiseResult): promiseResult is PromiseFulfilledResult<PickerItemPreloadData> => (
724                 promiseResult.status === 'fulfilled'
725             )).map(res => res.value)
726         });
727
728         // Group items to preload / ancestor data by home/shared picker and create initial Trees to preload
729         const initialTreePreloadData: PickerTreePreloadData[] = [
730             pickerItemsData.filter((item) => item.isHomeProjectItem),
731             pickerItemsData.filter((item) => !item.isHomeProjectItem),
732         ]
733             .filter((items) => items.length > 0)
734             .map((itemGroup) =>
735                 itemGroup.reduce(
736                     (preloadTree, itemData) => ({
737                         tree: createInitialPickerTree(
738                             itemData.ancestors,
739                             itemData.mainItemUuid,
740                             preloadTree.tree
741                         ),
742                         pickerTreeId: getPickerItemTreeId(itemData, homeUuid, pickerId),
743                         pickerTreeRootUuid: getPickerItemRootUuid(itemData, homeUuid),
744                     }),
745                     {
746                         tree: createTree<GroupResource | CollectionResource>(),
747                         pickerTreeId: '',
748                         pickerTreeRootUuid: '',
749                     } as PickerTreePreloadData
750                 )
751             );
752
753         // Load initial trees into corresponding picker store
754         await Promise.all(initialTreePreloadData.map(preloadTree => (
755             dispatch(
756                 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
757                     id: preloadTree.pickerTreeRootUuid,
758                     pickerId: preloadTree.pickerTreeId,
759                     subtree: preloadTree.tree,
760                 })
761             )
762         )));
763
764         // Await loading collection before attempting to select items
765         await Promise.all(pickerItemsData.map(async itemData => {
766             const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
767
768             // Selected item resides in collection subpath
769             if (itemData.itemId.includes('/')) {
770                 // Load collection into tree
771                 // loadCollection includes more than dispatched actions and must be awaited
772                 await dispatch(loadCollection(itemData.mainItemUuid, pickerTreeId, includeDirectories, includeFiles));
773             }
774             // Expand nodes down to destination
775             dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE_ANCESTORS({ id: itemData.itemId, pickerId: pickerTreeId }));
776         }));
777
778         // Select or activate nodes
779         pickerItemsData.forEach(itemData => {
780             const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
781
782             if (multi) {
783                 dispatch(treePickerActions.SELECT_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId, cascade: false}));
784             } else {
785                 dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId }));
786             }
787         });
788
789         // Refresh triggers loading in all adjacent items that were not included in the ancestor tree
790         await initialTreePreloadData.map(preloadTree => dispatch(treePickerSearchSagas.REFRESH_TREE_PICKER({ pickerId: preloadTree.pickerTreeId })));
791     }
792
793 const getPickerItemTreeId = (itemData: PickerItemPreloadData, homeUuid: string | undefined, pickerId: string) => {
794     const { home, shared } = getProjectsTreePickerIds(pickerId);
795     return ((itemData.isHomeProjectItem && homeUuid) ? home : shared);
796 };
797
798 const getPickerItemRootUuid = (itemData: PickerItemPreloadData, homeUuid: string | undefined) => {
799     return (itemData.isHomeProjectItem && homeUuid) ? homeUuid : SHARED_PROJECT_ID;
800 };
801
802 export const FAVORITES_PROJECT_ID = 'Favorites';
803 export const initFavoritesProject = (pickerId: string) =>
804     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
805         dispatch(receiveTreePickerData({
806             id: '',
807             pickerId,
808             data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
809             extractNodeData: value => ({
810                 id: value.uuid,
811                 status: TreeNodeStatus.INITIAL,
812                 value,
813             }),
814         }));
815     };
816
817 export const PUBLIC_FAVORITES_PROJECT_ID = 'Public Favorites';
818 export const initPublicFavoritesProject = (pickerId: string) =>
819     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
820         dispatch(receiveTreePickerData({
821             id: '',
822             pickerId,
823             data: [{ uuid: PUBLIC_FAVORITES_PROJECT_ID, name: PUBLIC_FAVORITES_PROJECT_ID }],
824             extractNodeData: value => ({
825                 id: value.uuid,
826                 status: TreeNodeStatus.INITIAL,
827                 value,
828             }),
829         }));
830     };
831
832 export const SEARCH_PROJECT_ID = 'Search all Projects';
833 export const initSearchProject = (pickerId: string) =>
834     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
835         dispatch(receiveTreePickerData({
836             id: '',
837             pickerId,
838             data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
839             extractNodeData: value => ({
840                 id: value.uuid,
841                 status: TreeNodeStatus.INITIAL,
842                 value,
843             }),
844         }));
845     };
846
847
848 interface LoadFavoritesProjectParams {
849     pickerId: string;
850     includeCollections?: boolean;
851     includeDirectories?: boolean;
852     includeFiles?: boolean;
853     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
854 }
855
856 export const loadFavoritesProject = (params: typeof treePickerSearchSagas._Record.LOAD_FAVORITES_PROJECT) => (treePickerSearchSagas.LOAD_FAVORITES_PROJECT(params));
857
858 export function* loadFavoritesProjectWatcher() {
859     yield takeEvery(treePickerSearchSagas.tags.LOAD_FAVORITES_PROJECT, loadFavoritesProjectSaga);
860 }
861
862 function* loadFavoritesProjectSaga({type, payload}: {
863     type: typeof treePickerSearchSagas.tags.LOAD_FAVORITES_PROJECT,
864     payload: typeof treePickerSearchSagas._Record.LOAD_FAVORITES_PROJECT,
865 }) {
866     try {
867         const services: ServiceRepository = yield getContext("services");
868         const state: RootState = yield select();
869
870         const {
871             pickerId,
872             includeCollections = false,
873             includeDirectories = false,
874             includeFiles = false,
875             options = { showOnlyOwned: true, showOnlyWritable: false },
876         } = payload;
877         const uuid = getUserUuid(state);
878         if (uuid) {
879             const filters = pipe(
880                 (fb: FilterBuilder) => includeCollections
881                     ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
882                     : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
883                 fb => fb.getFilters(),
884             )(new FilterBuilder());
885
886             const { items } = yield call(
887                 {context: services.favoriteService, fn: services.favoriteService.list},
888                 uuid,
889                 { filters },
890                 options.showOnlyOwned,
891             );
892
893             yield put(receiveTreePickerData<GroupContentsResource>({
894                 id: 'Favorites',
895                 pickerId,
896                 data: items.filter((item) => {
897                     if (options.showOnlyWritable && !(item as GroupResource).canWrite) {
898                         return false;
899                     }
900
901                     if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
902                         return false;
903                     }
904
905                     return true;
906                 }),
907                 extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
908             }));
909         }
910     } catch(e) {
911         yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load favorites`, kind: SnackbarKind.ERROR }));
912     }
913 }
914
915 export const loadPublicFavoritesProject = (params: typeof treePickerSearchSagas._Record.LOAD_PUBLIC_FAVORITES_PROJECT) => (treePickerSearchSagas.LOAD_PUBLIC_FAVORITES_PROJECT(params));
916
917 export function* loadPublicFavoritesProjectWatcher() {
918     yield takeEvery(treePickerSearchSagas.tags.LOAD_PUBLIC_FAVORITES_PROJECT, loadPublicFavoritesProjectSaga);
919 }
920
921 function* loadPublicFavoritesProjectSaga({type, payload}: {
922     type: typeof treePickerSearchSagas.tags.LOAD_PUBLIC_FAVORITES_PROJECT,
923     payload: typeof treePickerSearchSagas._Record.LOAD_PUBLIC_FAVORITES_PROJECT,
924 }) {
925     try {
926         const services: ServiceRepository = yield getContext("services");
927         const state: RootState = yield select();
928
929         const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false, options } = payload;
930         const uuidPrefix = state.auth.config.uuidPrefix;
931         const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
932
933         // TODO:
934         // favorites and public favorites ought to use a single method
935         // after getting back a list of links, need to look and stash the resources
936
937         const filters = pipe(
938             (fb: FilterBuilder) => includeCollections
939                 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
940                 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
941             fb => fb
942                 .addEqual('link_class', LinkClass.STAR)
943                 .addEqual('owner_uuid', publicProjectUuid)
944                 .getFilters(),
945         )(new FilterBuilder());
946
947         const { items } = yield call(
948             {context: services.linkService, fn: services.linkService.list},
949             { filters },
950         );
951
952         yield put(receiveTreePickerData<LinkResource>({
953             id: 'Public Favorites',
954             pickerId,
955             data: items.filter(item => {
956                 if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
957                     return false;
958                 }
959
960                 return true;
961             }),
962             extractNodeData: item => ({
963                 id: item.headUuid,
964                 value: item,
965                 status: item.headKind === ResourceKind.PROJECT
966                     ? TreeNodeStatus.INITIAL
967                     : includeDirectories || includeFiles
968                         ? TreeNodeStatus.INITIAL
969                         : TreeNodeStatus.LOADED
970             }),
971         }));
972     } catch (e) {
973         yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load public favorites`, kind: SnackbarKind.ERROR }));
974     }
975 }
976
977 export const receiveTreePickerProjectsData = (id: string, projects: ProjectResource[], pickerId: string) =>
978     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
979         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
980             id,
981             nodes: projects.map(project => initTreeNode({ id: project.uuid, value: project })),
982             pickerId,
983         }));
984
985         dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
986     };
987
988 export const loadProjectTreePickerProjects = (id: string) =>
989     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
990         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PROJECTS }));
991
992
993         const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
994         const { items } = await services.projectService.list(buildParams(ownerUuid));
995
996         dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
997     };
998
999 export const loadFavoriteTreePickerProjects = (id: string) =>
1000     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
1001         const parentId = getUserUuid(getState()) || '';
1002
1003         if (id === '') {
1004             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.FAVORITES }));
1005             const { items } = await services.favoriteService.list(parentId);
1006             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
1007         } else {
1008             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.FAVORITES }));
1009             const { items } = await services.projectService.list(buildParams(id));
1010             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.FAVORITES));
1011         }
1012
1013     };
1014
1015 export const loadPublicFavoriteTreePickerProjects = (id: string) =>
1016     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
1017         const parentId = getUserUuid(getState()) || '';
1018
1019         if (id === '') {
1020             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.PUBLIC_FAVORITES }));
1021             const { items } = await services.favoriteService.list(parentId);
1022             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.PUBLIC_FAVORITES));
1023         } else {
1024             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PUBLIC_FAVORITES }));
1025             const { items } = await services.projectService.list(buildParams(id));
1026             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PUBLIC_FAVORITES));
1027         }
1028
1029     };
1030
1031 const buildParams = (ownerUuid: string) => {
1032     return {
1033         filters: new FilterBuilder()
1034             .addEqual('owner_uuid', ownerUuid)
1035             .getFilters(),
1036         order: new OrderBuilder<ProjectResource>()
1037             .addAsc('name')
1038             .getOrder()
1039     };
1040 };
1041
1042 /**
1043  * Given a tree picker item, return collection uuid and path
1044  *   if the item represents a valid target/destination location
1045  */
1046 export type FileOperationLocation = {
1047     name: string;
1048     uuid: string;
1049     pdh?: string;
1050     subpath: string;
1051 }
1052 export const getFileOperationLocation = (item: ProjectsTreePickerItem) =>
1053     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<FileOperationLocation | undefined> => {
1054         if ('kind' in item && item.kind === ResourceKind.COLLECTION) {
1055             return {
1056                 name: item.name,
1057                 uuid: item.uuid,
1058                 pdh: item.portableDataHash,
1059                 subpath: '/',
1060             };
1061         } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
1062             const uuid = getCollectionResourceCollectionUuid(item.id);
1063             if (uuid) {
1064                 const collection = getResource<CollectionResource>(uuid)(getState().resources);
1065                 if (collection) {
1066                     const itemPath = [item.path, item.name].join('/');
1067
1068                     return {
1069                         name: item.name,
1070                         uuid,
1071                         pdh: collection.portableDataHash,
1072                         subpath: itemPath,
1073                     };
1074                 }
1075             }
1076         }
1077         return undefined;
1078     };
1079
1080 /**
1081  * Create an expanded tree picker subtree from array of nested projects/collection
1082  *   First item is assumed to be root and gets empty parent id
1083  *   Nodes must be sorted from top down to prevent orphaned nodes
1084  */
1085 export const createInitialPickerTree = (sortedAncestors: Array<GroupResource | CollectionResource>, tailUuid: string, initialTree: Tree<GroupResource | CollectionResource>) => {
1086     return sortedAncestors
1087         .reduce((tree, item, index) => {
1088             if (getNode(item.uuid)(tree)) {
1089                 return tree;
1090             } else {
1091                 return setNode({
1092                     children: [],
1093                     id: item.uuid,
1094                     parent: index === 0 ? '' : item.ownerUuid,
1095                     value: item,
1096                     active: false,
1097                     selected: false,
1098                     expanded: false,
1099                     status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
1100                 })(tree);
1101             }
1102         }, initialTree);
1103 };
1104
1105 export const fileOperationLocationToPickerId = (location: FileOperationLocation): string => {
1106     let id = location.uuid;
1107     if (location.subpath.length && location.subpath !== '/') {
1108         id = id + location.subpath;
1109     }
1110     return id;
1111 }