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