Merge branch '21128-toolbar-context-menu'
[arvados-workbench2.git] / src / store / tree-picker / tree-picker-actions.ts
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import { unionize, ofType, UnionOf } from "common/unionize";
6 import { TreeNode, initTreeNode, getNodeDescendants, TreeNodeStatus, getNode, TreePickerId, Tree, 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 } from 'models/resource';
15 import { GroupContentsResource } from 'services/groups-service/groups-service';
16 import { getTreePicker, TreePicker } from './tree-picker';
17 import { ProjectsTreePickerItem } from './tree-picker-middleware';
18 import { OrderBuilder } from 'services/api/order-builder';
19 import { ProjectResource } from 'models/project';
20 import { mapTree } from '../../models/tree';
21 import { LinkResource, LinkClass } from "models/link";
22 import { mapTreeValues } from "models/tree";
23 import { sortFilesTree } from "services/collection-service/collection-service-files-response";
24 import { GroupClass, GroupResource } from "models/group";
25 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
30 export const treePickerActions = unionize({
31     LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
32     LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array<TreeNode<any>>, pickerId: string }>(),
33     APPEND_TREE_PICKER_NODE_SUBTREE: ofType<{ id: string, subtree: Tree<any>, pickerId: string }>(),
34     TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string, pickerId: string }>(),
35     EXPAND_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
36     EXPAND_TREE_PICKER_NODE_ANCESTORS: ofType<{ id: string, pickerId: string }>(),
37     ACTIVATE_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string, relatedTreePickers?: string[] }>(),
38     DEACTIVATE_TREE_PICKER_NODE: ofType<{ pickerId: string }>(),
39     TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string, cascade: boolean }>(),
40     SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(),
41     DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(),
42     EXPAND_TREE_PICKER_NODES: ofType<{ ids: string[], pickerId: string }>(),
43     RESET_TREE_PICKER: ofType<{ pickerId: string }>()
44 });
45
46 export type TreePickerAction = UnionOf<typeof treePickerActions>;
47
48 export interface LoadProjectParams {
49     includeCollections?: boolean;
50     includeDirectories?: boolean;
51     includeFiles?: boolean;
52     includeFilterGroups?: boolean;
53     options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; };
54 }
55
56 export const treePickerSearchActions = unionize({
57     SET_TREE_PICKER_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(),
58     SET_TREE_PICKER_COLLECTION_FILTER: ofType<{ pickerId: string, collectionFilterValue: string }>(),
59     SET_TREE_PICKER_LOAD_PARAMS: ofType<{ pickerId: string, params: LoadProjectParams }>(),
60     REFRESH_TREE_PICKER: ofType<{ pickerId: string }>(),
61 });
62
63 export type TreePickerSearchAction = UnionOf<typeof treePickerSearchActions>;
64
65 export const getProjectsTreePickerIds = (pickerId: string) => ({
66     home: `${pickerId}_home`,
67     shared: `${pickerId}_shared`,
68     favorites: `${pickerId}_favorites`,
69     publicFavorites: `${pickerId}_publicFavorites`,
70     search: `${pickerId}_search`,
71 });
72
73 export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
74     pipe(
75         () => values(getProjectsTreePickerIds(pickerId)),
76
77         ids => ids
78             .map(id => getTreePicker<Value>(id)(state)),
79
80         trees => trees
81             .map(getNodeDescendants(''))
82             .reduce((allNodes, nodes) => allNodes.concat(nodes), []),
83
84         allNodes => allNodes
85             .reduce((map, node) =>
86                 filter(node)
87                     ? map.set(node.id, node)
88                     : map, new Map<string, TreeNode<Value>>())
89             .values(),
90
91         uniqueNodes => Array.from(uniqueNodes),
92     )();
93 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
94     getAllNodes<Value>(pickerId, node => node.selected)(state);
95
96 interface TreePickerPreloadParams {
97     selectedItemUuids: string[];
98     includeDirectories: boolean;
99     includeFiles: boolean;
100     multi: boolean;
101 }
102
103 export const initProjectsTreePicker = (pickerId: string, preloadParams?: TreePickerPreloadParams) =>
104     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
105         const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
106         dispatch<any>(initUserProject(home));
107         dispatch<any>(initSharedProject(shared));
108         dispatch<any>(initFavoritesProject(favorites));
109         dispatch<any>(initPublicFavoritesProject(publicFavorites));
110         dispatch<any>(initSearchProject(search));
111
112         if (preloadParams && preloadParams.selectedItemUuids.length) {
113             await dispatch<any>(loadInitialValue(
114                 preloadParams.selectedItemUuids,
115                 pickerId,
116                 preloadParams.includeDirectories,
117                 preloadParams.includeFiles,
118                 preloadParams.multi
119             ));
120         }
121     };
122
123 interface ReceiveTreePickerDataParams<T> {
124     data: T[];
125     extractNodeData: (value: T) => { id: string, value: T, status?: TreeNodeStatus };
126     id: string;
127     pickerId: string;
128 }
129
130 export const receiveTreePickerData = <T>(params: ReceiveTreePickerDataParams<T>) =>
131     (dispatch: Dispatch) => {
132         const { data, extractNodeData, id, pickerId, } = params;
133         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
134             id,
135             nodes: data.map(item => initTreeNode(extractNodeData(item))),
136             pickerId,
137         }));
138         dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
139     };
140
141 interface LoadProjectParamsWithId extends LoadProjectParams {
142     id: string;
143     pickerId: string;
144     loadShared?: boolean;
145     searchProjects?: boolean;
146 }
147
148 /**
149  * loadProject is used to load or refresh a project node in a tree picker
150  *   Errors are caught and a toast is shown if the project fails to load
151  */
152 export const loadProject = (params: LoadProjectParamsWithId) =>
153     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
154         const {
155             id,
156             pickerId,
157             includeCollections = false,
158             includeDirectories = false,
159             includeFiles = false,
160             includeFilterGroups = false,
161             loadShared = false,
162             options,
163             searchProjects = false
164         } = params;
165
166         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
167
168         let filterB = new FilterBuilder();
169
170         filterB = (includeCollections && !searchProjects)
171             ? filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
172             : filterB.addIsA('uuid', [ResourceKind.PROJECT]);
173
174         const state = getState();
175
176         if (state.treePickerSearch.collectionFilterValues[pickerId]) {
177             filterB = filterB.addFullTextSearch(state.treePickerSearch.collectionFilterValues[pickerId], 'collections');
178         } else {
179             filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
180         }
181
182         if (searchProjects && state.treePickerSearch.projectSearchValues[pickerId]) {
183             filterB = filterB.addFullTextSearch(state.treePickerSearch.projectSearchValues[pickerId], 'groups');
184         }
185
186         const filters = filterB.getFilters();
187
188         const itemLimit = 200;
189
190         try {
191             const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit });
192             dispatch<any>(updateResources(items));
193
194             if (itemsAvailable > itemLimit) {
195                 items.push({
196                     uuid: "more-items-available",
197                     kind: ResourceKind.WORKFLOW,
198                     name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`,
199                     description: "",
200                     definition: "",
201                     ownerUuid: "",
202                     createdAt: "",
203                     modifiedByClientUuid: "",
204                     modifiedByUserUuid: "",
205                     modifiedAt: "",
206                     href: "",
207                     etag: ""
208                 });
209             }
210
211             dispatch<any>(receiveTreePickerData<GroupContentsResource>({
212                 id,
213                 pickerId,
214                 data: items.filter((item) => {
215                     if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
216                         return false;
217                     }
218
219                     if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
220                         return false;
221                     }
222
223                     return true;
224                 }),
225                 extractNodeData: item => (
226                     item.uuid === "more-items-available" ?
227                         {
228                             id: item.uuid,
229                             value: item,
230                             status: TreeNodeStatus.LOADED
231                         }
232                         : {
233                             id: item.uuid,
234                             value: item,
235                             status: item.kind === ResourceKind.PROJECT
236                                 ? TreeNodeStatus.INITIAL
237                                 : includeDirectories || includeFiles
238                                     ? TreeNodeStatus.INITIAL
239                                     : TreeNodeStatus.LOADED
240                         }),
241             }));
242         } catch(e) {
243             console.error("Failed to load project into tree picker:", e);;
244             dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load project`, kind: SnackbarKind.ERROR }));
245         }
246     };
247
248 export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) =>
249     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
250         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
251
252         const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
253         if (picker) {
254
255             const node = getNode(id)(picker);
256             if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
257                 const files = (await services.collectionService.files(node.value.uuid))
258                     .filter((file) => (
259                         (includeFiles) ||
260                         (includeDirectories && file.type === CollectionFileType.DIRECTORY)
261                     ));
262                 const tree = createCollectionFilesTree(files);
263                 const sorted = sortFilesTree(tree);
264                 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
265
266                 // await tree modifications so that consumers can guarantee node presence
267                 await dispatch(
268                     treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
269                         id,
270                         pickerId,
271                         subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
272                     }));
273
274                 // Expand collection root node
275                 dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
276             }
277         }
278     };
279
280 export const HOME_PROJECT_ID = 'Home Projects';
281 export const initUserProject = (pickerId: string) =>
282     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
283         const uuid = getUserUuid(getState());
284         if (uuid) {
285             dispatch(receiveTreePickerData({
286                 id: '',
287                 pickerId,
288                 data: [{ uuid, name: HOME_PROJECT_ID }],
289                 extractNodeData: value => ({
290                     id: value.uuid,
291                     status: TreeNodeStatus.INITIAL,
292                     value,
293                 }),
294             }));
295         }
296     };
297 export const loadUserProject = (pickerId: string, includeCollections = false, includeDirectories = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
298     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
299         const uuid = getUserUuid(getState());
300         if (uuid) {
301             dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
302         }
303     };
304
305 export const SHARED_PROJECT_ID = 'Shared with me';
306 export const initSharedProject = (pickerId: string) =>
307     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
308         dispatch(receiveTreePickerData({
309             id: '',
310             pickerId,
311             data: [{ uuid: SHARED_PROJECT_ID, name: SHARED_PROJECT_ID }],
312             extractNodeData: value => ({
313                 id: value.uuid,
314                 status: TreeNodeStatus.INITIAL,
315                 value,
316             }),
317         }));
318     };
319
320 type PickerItemPreloadData = {
321     itemId: string;
322     mainItemUuid: string;
323     ancestors: (GroupResource | CollectionResource)[];
324     isHomeProjectItem: boolean;
325 }
326
327 type PickerTreePreloadData = {
328     tree: Tree<GroupResource | CollectionResource>;
329     pickerTreeId: string;
330     pickerTreeRootUuid: string;
331 };
332
333 export const loadInitialValue = (pickerItemIds: string[], pickerId: string, includeDirectories: boolean, includeFiles: boolean, multi: boolean,) =>
334     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
335         const homeUuid = getUserUuid(getState());
336
337         // Request ancestor trees in paralell and save home project status
338         const pickerItemsData: PickerItemPreloadData[] = await Promise.allSettled(pickerItemIds.map(async itemId => {
339             const mainItemUuid = itemId.includes('/') ? itemId.split('/')[0] : itemId;
340
341             const ancestors = (await services.ancestorsService.ancestors(mainItemUuid, ''))
342             .filter(item =>
343                 item.kind === ResourceKind.GROUP ||
344                 item.kind === ResourceKind.COLLECTION
345             ) as (GroupResource | CollectionResource)[];
346
347             if (ancestors.length === 0) {
348                 return Promise.reject({item: itemId});
349             }
350
351             const isHomeProjectItem = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid));
352
353             return {
354                 itemId,
355                 mainItemUuid,
356                 ancestors,
357                 isHomeProjectItem,
358             };
359         })).then((res) => {
360             // Show toast if any selections failed to restore
361             const rejectedPromises = res.filter((promiseResult): promiseResult is PromiseRejectedResult => (promiseResult.status === 'rejected'));
362             if (rejectedPromises.length) {
363                 rejectedPromises.forEach(item => {
364                     console.error("The following item failed to load into the tree picker", item.reason);
365                 });
366                 dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Some selections failed to load and were removed. See console for details.`, kind: SnackbarKind.ERROR }));
367             }
368             // Filter out any failed promises and map to resulting preload data with ancestors
369             return res.filter((promiseResult): promiseResult is PromiseFulfilledResult<PickerItemPreloadData> => (
370                 promiseResult.status === 'fulfilled'
371             )).map(res => res.value)
372         });
373
374         // Group items to preload / ancestor data by home/shared picker and create initial Trees to preload
375         const initialTreePreloadData: PickerTreePreloadData[] = [
376             pickerItemsData.filter((item) => item.isHomeProjectItem),
377             pickerItemsData.filter((item) => !item.isHomeProjectItem),
378         ]
379             .filter((items) => items.length > 0)
380             .map((itemGroup) =>
381                 itemGroup.reduce(
382                     (preloadTree, itemData) => ({
383                         tree: createInitialPickerTree(
384                             itemData.ancestors,
385                             itemData.mainItemUuid,
386                             preloadTree.tree
387                         ),
388                         pickerTreeId: getPickerItemTreeId(itemData, homeUuid, pickerId),
389                         pickerTreeRootUuid: getPickerItemRootUuid(itemData, homeUuid),
390                     }),
391                     {
392                         tree: createTree<GroupResource | CollectionResource>(),
393                         pickerTreeId: '',
394                         pickerTreeRootUuid: '',
395                     } as PickerTreePreloadData
396                 )
397             );
398
399         // Load initial trees into corresponding picker store
400         await Promise.all(initialTreePreloadData.map(preloadTree => (
401             dispatch(
402                 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
403                     id: preloadTree.pickerTreeRootUuid,
404                     pickerId: preloadTree.pickerTreeId,
405                     subtree: preloadTree.tree,
406                 })
407             )
408         )));
409
410         // Await loading collection before attempting to select items
411         await Promise.all(pickerItemsData.map(async itemData => {
412             const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
413
414             // Selected item resides in collection subpath
415             if (itemData.itemId.includes('/')) {
416                 // Load collection into tree
417                 // loadCollection includes more than dispatched actions and must be awaited
418                 await dispatch(loadCollection(itemData.mainItemUuid, pickerTreeId, includeDirectories, includeFiles));
419             }
420             // Expand nodes down to destination
421             dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE_ANCESTORS({ id: itemData.itemId, pickerId: pickerTreeId }));
422         }));
423
424         // Select or activate nodes
425         pickerItemsData.forEach(itemData => {
426             const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
427
428             if (multi) {
429                 dispatch(treePickerActions.SELECT_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId, cascade: false}));
430             } else {
431                 dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId }));
432             }
433         });
434
435         // Refresh triggers loading in all adjacent items that were not included in the ancestor tree
436         await initialTreePreloadData.map(preloadTree => dispatch(treePickerSearchActions.REFRESH_TREE_PICKER({ pickerId: preloadTree.pickerTreeId })));
437     }
438
439 const getPickerItemTreeId = (itemData: PickerItemPreloadData, homeUuid: string | undefined, pickerId: string) => {
440     const { home, shared } = getProjectsTreePickerIds(pickerId);
441     return ((itemData.isHomeProjectItem && homeUuid) ? home : shared);
442 };
443
444 const getPickerItemRootUuid = (itemData: PickerItemPreloadData, homeUuid: string | undefined) => {
445     return (itemData.isHomeProjectItem && homeUuid) ? homeUuid : SHARED_PROJECT_ID;
446 };
447
448 export const FAVORITES_PROJECT_ID = 'Favorites';
449 export const initFavoritesProject = (pickerId: string) =>
450     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
451         dispatch(receiveTreePickerData({
452             id: '',
453             pickerId,
454             data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
455             extractNodeData: value => ({
456                 id: value.uuid,
457                 status: TreeNodeStatus.INITIAL,
458                 value,
459             }),
460         }));
461     };
462
463 export const PUBLIC_FAVORITES_PROJECT_ID = 'Public Favorites';
464 export const initPublicFavoritesProject = (pickerId: string) =>
465     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
466         dispatch(receiveTreePickerData({
467             id: '',
468             pickerId,
469             data: [{ uuid: PUBLIC_FAVORITES_PROJECT_ID, name: PUBLIC_FAVORITES_PROJECT_ID }],
470             extractNodeData: value => ({
471                 id: value.uuid,
472                 status: TreeNodeStatus.INITIAL,
473                 value,
474             }),
475         }));
476     };
477
478 export const SEARCH_PROJECT_ID = 'Search all Projects';
479 export const initSearchProject = (pickerId: string) =>
480     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
481         dispatch(receiveTreePickerData({
482             id: '',
483             pickerId,
484             data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
485             extractNodeData: value => ({
486                 id: value.uuid,
487                 status: TreeNodeStatus.INITIAL,
488                 value,
489             }),
490         }));
491     };
492
493
494 interface LoadFavoritesProjectParams {
495     pickerId: string;
496     includeCollections?: boolean;
497     includeDirectories?: boolean;
498     includeFiles?: boolean;
499     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
500 }
501
502 export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
503     options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) =>
504     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
505         const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
506         const uuid = getUserUuid(getState());
507         if (uuid) {
508             const filters = pipe(
509                 (fb: FilterBuilder) => includeCollections
510                     ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
511                     : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
512                 fb => fb.getFilters(),
513             )(new FilterBuilder());
514
515             const { items } = await services.favoriteService.list(uuid, { filters }, options.showOnlyOwned);
516
517             dispatch<any>(receiveTreePickerData<GroupContentsResource>({
518                 id: 'Favorites',
519                 pickerId,
520                 data: items.filter((item) => {
521                     if (options.showOnlyWritable && !(item as GroupResource).canWrite) {
522                         return false;
523                     }
524
525                     if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
526                         return false;
527                     }
528
529                     return true;
530                 }),
531                 extractNodeData: item => ({
532                     id: item.uuid,
533                     value: item,
534                     status: item.kind === ResourceKind.PROJECT
535                         ? TreeNodeStatus.INITIAL
536                         : includeDirectories || includeFiles
537                             ? TreeNodeStatus.INITIAL
538                             : TreeNodeStatus.LOADED
539                 }),
540             }));
541         }
542     };
543
544 export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =>
545     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
546         const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
547         const uuidPrefix = getState().auth.config.uuidPrefix;
548         const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
549
550         const filters = pipe(
551             (fb: FilterBuilder) => includeCollections
552                 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
553                 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
554             fb => fb
555                 .addEqual('link_class', LinkClass.STAR)
556                 .addEqual('owner_uuid', publicProjectUuid)
557                 .getFilters(),
558         )(new FilterBuilder());
559
560         const { items } = await services.linkService.list({ filters });
561
562         dispatch<any>(receiveTreePickerData<LinkResource>({
563             id: 'Public Favorites',
564             pickerId,
565             data: items.filter(item => {
566                 if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
567                     return false;
568                 }
569
570                 return true;
571             }),
572             extractNodeData: item => ({
573                 id: item.headUuid,
574                 value: item,
575                 status: item.headKind === ResourceKind.PROJECT
576                     ? TreeNodeStatus.INITIAL
577                     : includeDirectories || includeFiles
578                         ? TreeNodeStatus.INITIAL
579                         : TreeNodeStatus.LOADED
580             }),
581         }));
582     };
583
584 export const receiveTreePickerProjectsData = (id: string, projects: ProjectResource[], pickerId: string) =>
585     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
586         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
587             id,
588             nodes: projects.map(project => initTreeNode({ id: project.uuid, value: project })),
589             pickerId,
590         }));
591
592         dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
593     };
594
595 export const loadProjectTreePickerProjects = (id: string) =>
596     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
597         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PROJECTS }));
598
599
600         const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
601         const { items } = await services.projectService.list(buildParams(ownerUuid));
602
603         dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
604     };
605
606 export const loadFavoriteTreePickerProjects = (id: string) =>
607     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
608         const parentId = getUserUuid(getState()) || '';
609
610         if (id === '') {
611             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.FAVORITES }));
612             const { items } = await services.favoriteService.list(parentId);
613             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
614         } else {
615             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.FAVORITES }));
616             const { items } = await services.projectService.list(buildParams(id));
617             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.FAVORITES));
618         }
619
620     };
621
622 export const loadPublicFavoriteTreePickerProjects = (id: string) =>
623     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
624         const parentId = getUserUuid(getState()) || '';
625
626         if (id === '') {
627             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.PUBLIC_FAVORITES }));
628             const { items } = await services.favoriteService.list(parentId);
629             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.PUBLIC_FAVORITES));
630         } else {
631             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PUBLIC_FAVORITES }));
632             const { items } = await services.projectService.list(buildParams(id));
633             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PUBLIC_FAVORITES));
634         }
635
636     };
637
638 const buildParams = (ownerUuid: string) => {
639     return {
640         filters: new FilterBuilder()
641             .addEqual('owner_uuid', ownerUuid)
642             .getFilters(),
643         order: new OrderBuilder<ProjectResource>()
644             .addAsc('name')
645             .getOrder()
646     };
647 };
648
649 /**
650  * Given a tree picker item, return collection uuid and path
651  *   if the item represents a valid target/destination location
652  */
653 export type FileOperationLocation = {
654     name: string;
655     uuid: string;
656     pdh?: string;
657     subpath: string;
658 }
659 export const getFileOperationLocation = (item: ProjectsTreePickerItem) =>
660     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<FileOperationLocation | undefined> => {
661         if ('kind' in item && item.kind === ResourceKind.COLLECTION) {
662             return {
663                 name: item.name,
664                 uuid: item.uuid,
665                 pdh: item.portableDataHash,
666                 subpath: '/',
667             };
668         } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
669             const uuid = getCollectionResourceCollectionUuid(item.id);
670             if (uuid) {
671                 const collection = getResource<CollectionResource>(uuid)(getState().resources);
672                 if (collection) {
673                     const itemPath = [item.path, item.name].join('/');
674
675                     return {
676                         name: item.name,
677                         uuid,
678                         pdh: collection.portableDataHash,
679                         subpath: itemPath,
680                     };
681                 }
682             }
683         }
684         return undefined;
685     };
686
687 /**
688  * Create an expanded tree picker subtree from array of nested projects/collection
689  *   First item is assumed to be root and gets empty parent id
690  *   Nodes must be sorted from top down to prevent orphaned nodes
691  */
692 export const createInitialPickerTree = (sortedAncestors: Array<GroupResource | CollectionResource>, tailUuid: string, initialTree: Tree<GroupResource | CollectionResource>) => {
693     return sortedAncestors
694         .reduce((tree, item, index) => {
695             if (getNode(item.uuid)(tree)) {
696                 return tree;
697             } else {
698                 return setNode({
699                     children: [],
700                     id: item.uuid,
701                     parent: index === 0 ? '' : item.ownerUuid,
702                     value: item,
703                     active: false,
704                     selected: false,
705                     expanded: false,
706                     status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
707                 })(tree);
708             }
709         }, initialTree);
710 };
711
712 export const fileOperationLocationToPickerId = (location: FileOperationLocation): string => {
713     let id = location.uuid;
714     if (location.subpath.length && location.subpath !== '/') {
715         id = id + location.subpath;
716     }
717     return id;
718 }