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