20225: Catch tree picker loadproject errors and show toast / console error with proje...
[arvados.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             const ancestors = (await services.ancestorsService.ancestors(mainItemUuid, ''))
341             .filter(item =>
342                 item.kind === ResourceKind.GROUP ||
343                 item.kind === ResourceKind.COLLECTION
344             ) as (GroupResource | CollectionResource)[];
345
346             const isHomeProjectItem = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid));
347
348             return {
349                 itemId,
350                 mainItemUuid,
351                 ancestors,
352                 isHomeProjectItem,
353             };
354         })).then((res) => {
355             // Show toast if any selections failed to restore
356             if (res.find((promiseResult): promiseResult is PromiseRejectedResult => (promiseResult.status === 'rejected'))) {
357                 dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Some selections failed to load and were removed`, kind: SnackbarKind.ERROR }));
358             }
359             // Filter out any failed promises and map to resulting preload data with ancestors
360             return res.filter((promiseResult): promiseResult is PromiseFulfilledResult<PickerItemPreloadData> => (
361                 promiseResult.status === 'fulfilled'
362             )).map(res => res.value)
363         });
364
365         // Group items to preload / ancestor data by home/shared picker and create initial Trees to preload
366         const initialTreePreloadData: PickerTreePreloadData[] = [
367             pickerItemsData.filter((item) => item.isHomeProjectItem),
368             pickerItemsData.filter((item) => !item.isHomeProjectItem),
369         ]
370             .filter((items) => items.length > 0)
371             .map((itemGroup) =>
372                 itemGroup.reduce(
373                     (preloadTree, itemData) => ({
374                         tree: createInitialPickerTree(
375                             itemData.ancestors,
376                             itemData.mainItemUuid,
377                             preloadTree.tree
378                         ),
379                         pickerTreeId: getPickerItemTreeId(itemData, homeUuid, pickerId),
380                         pickerTreeRootUuid: getPickerItemRootUuid(itemData, homeUuid),
381                     }),
382                     {
383                         tree: createTree<GroupResource | CollectionResource>(),
384                         pickerTreeId: '',
385                         pickerTreeRootUuid: '',
386                     } as PickerTreePreloadData
387                 )
388             );
389
390         // Load initial trees into corresponding picker store
391         await Promise.all(initialTreePreloadData.map(preloadTree => (
392             dispatch(
393                 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
394                     id: preloadTree.pickerTreeRootUuid,
395                     pickerId: preloadTree.pickerTreeId,
396                     subtree: preloadTree.tree,
397                 })
398             )
399         )));
400
401         // Await loading collection before attempting to select items
402         await Promise.all(pickerItemsData.map(async itemData => {
403             const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
404
405             // Selected item resides in collection subpath
406             if (itemData.itemId.includes('/')) {
407                 // Load collection into tree
408                 // loadCollection includes more than dispatched actions and must be awaited
409                 await dispatch(loadCollection(itemData.mainItemUuid, pickerTreeId, includeDirectories, includeFiles));
410             }
411             // Expand nodes down to destination
412             dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE_ANCESTORS({ id: itemData.itemId, pickerId: pickerTreeId }));
413         }));
414
415         // Select or activate nodes
416         pickerItemsData.forEach(itemData => {
417             const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
418
419             if (multi) {
420                 dispatch(treePickerActions.SELECT_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId, cascade: false}));
421             } else {
422                 dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId }));
423             }
424         });
425
426         // Refresh triggers loading in all adjacent items that were not included in the ancestor tree
427         await initialTreePreloadData.map(preloadTree => dispatch(treePickerSearchActions.REFRESH_TREE_PICKER({ pickerId: preloadTree.pickerTreeId })));
428     }
429
430 const getPickerItemTreeId = (itemData: PickerItemPreloadData, homeUuid: string | undefined, pickerId: string) => {
431     const { home, shared } = getProjectsTreePickerIds(pickerId);
432     return ((itemData.isHomeProjectItem && homeUuid) ? home : shared);
433 };
434
435 const getPickerItemRootUuid = (itemData: PickerItemPreloadData, homeUuid: string | undefined) => {
436     return (itemData.isHomeProjectItem && homeUuid) ? homeUuid : SHARED_PROJECT_ID;
437 };
438
439 export const FAVORITES_PROJECT_ID = 'Favorites';
440 export const initFavoritesProject = (pickerId: string) =>
441     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
442         dispatch(receiveTreePickerData({
443             id: '',
444             pickerId,
445             data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
446             extractNodeData: value => ({
447                 id: value.uuid,
448                 status: TreeNodeStatus.INITIAL,
449                 value,
450             }),
451         }));
452     };
453
454 export const PUBLIC_FAVORITES_PROJECT_ID = 'Public Favorites';
455 export const initPublicFavoritesProject = (pickerId: string) =>
456     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
457         dispatch(receiveTreePickerData({
458             id: '',
459             pickerId,
460             data: [{ uuid: PUBLIC_FAVORITES_PROJECT_ID, name: PUBLIC_FAVORITES_PROJECT_ID }],
461             extractNodeData: value => ({
462                 id: value.uuid,
463                 status: TreeNodeStatus.INITIAL,
464                 value,
465             }),
466         }));
467     };
468
469 export const SEARCH_PROJECT_ID = 'Search all Projects';
470 export const initSearchProject = (pickerId: string) =>
471     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
472         dispatch(receiveTreePickerData({
473             id: '',
474             pickerId,
475             data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
476             extractNodeData: value => ({
477                 id: value.uuid,
478                 status: TreeNodeStatus.INITIAL,
479                 value,
480             }),
481         }));
482     };
483
484
485 interface LoadFavoritesProjectParams {
486     pickerId: string;
487     includeCollections?: boolean;
488     includeDirectories?: boolean;
489     includeFiles?: boolean;
490     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
491 }
492
493 export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
494     options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) =>
495     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
496         const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
497         const uuid = getUserUuid(getState());
498         if (uuid) {
499             const filters = pipe(
500                 (fb: FilterBuilder) => includeCollections
501                     ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
502                     : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
503                 fb => fb.getFilters(),
504             )(new FilterBuilder());
505
506             const { items } = await services.favoriteService.list(uuid, { filters }, options.showOnlyOwned);
507
508             dispatch<any>(receiveTreePickerData<GroupContentsResource>({
509                 id: 'Favorites',
510                 pickerId,
511                 data: items.filter((item) => {
512                     if (options.showOnlyWritable && !(item as GroupResource).canWrite) {
513                         return false;
514                     }
515
516                     if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
517                         return false;
518                     }
519
520                     return true;
521                 }),
522                 extractNodeData: item => ({
523                     id: item.uuid,
524                     value: item,
525                     status: item.kind === ResourceKind.PROJECT
526                         ? TreeNodeStatus.INITIAL
527                         : includeDirectories || includeFiles
528                             ? TreeNodeStatus.INITIAL
529                             : TreeNodeStatus.LOADED
530                 }),
531             }));
532         }
533     };
534
535 export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =>
536     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
537         const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
538         const uuidPrefix = getState().auth.config.uuidPrefix;
539         const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
540
541         const filters = pipe(
542             (fb: FilterBuilder) => includeCollections
543                 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
544                 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
545             fb => fb
546                 .addEqual('link_class', LinkClass.STAR)
547                 .addEqual('owner_uuid', publicProjectUuid)
548                 .getFilters(),
549         )(new FilterBuilder());
550
551         const { items } = await services.linkService.list({ filters });
552
553         dispatch<any>(receiveTreePickerData<LinkResource>({
554             id: 'Public Favorites',
555             pickerId,
556             data: items.filter(item => {
557                 if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
558                     return false;
559                 }
560
561                 return true;
562             }),
563             extractNodeData: item => ({
564                 id: item.headUuid,
565                 value: item,
566                 status: item.headKind === ResourceKind.PROJECT
567                     ? TreeNodeStatus.INITIAL
568                     : includeDirectories || includeFiles
569                         ? TreeNodeStatus.INITIAL
570                         : TreeNodeStatus.LOADED
571             }),
572         }));
573     };
574
575 export const receiveTreePickerProjectsData = (id: string, projects: ProjectResource[], pickerId: string) =>
576     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
577         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
578             id,
579             nodes: projects.map(project => initTreeNode({ id: project.uuid, value: project })),
580             pickerId,
581         }));
582
583         dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
584     };
585
586 export const loadProjectTreePickerProjects = (id: string) =>
587     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
588         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PROJECTS }));
589
590
591         const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
592         const { items } = await services.projectService.list(buildParams(ownerUuid));
593
594         dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
595     };
596
597 export const loadFavoriteTreePickerProjects = (id: string) =>
598     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
599         const parentId = getUserUuid(getState()) || '';
600
601         if (id === '') {
602             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.FAVORITES }));
603             const { items } = await services.favoriteService.list(parentId);
604             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
605         } else {
606             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.FAVORITES }));
607             const { items } = await services.projectService.list(buildParams(id));
608             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.FAVORITES));
609         }
610
611     };
612
613 export const loadPublicFavoriteTreePickerProjects = (id: string) =>
614     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
615         const parentId = getUserUuid(getState()) || '';
616
617         if (id === '') {
618             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.PUBLIC_FAVORITES }));
619             const { items } = await services.favoriteService.list(parentId);
620             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.PUBLIC_FAVORITES));
621         } else {
622             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PUBLIC_FAVORITES }));
623             const { items } = await services.projectService.list(buildParams(id));
624             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PUBLIC_FAVORITES));
625         }
626
627     };
628
629 const buildParams = (ownerUuid: string) => {
630     return {
631         filters: new FilterBuilder()
632             .addEqual('owner_uuid', ownerUuid)
633             .getFilters(),
634         order: new OrderBuilder<ProjectResource>()
635             .addAsc('name')
636             .getOrder()
637     };
638 };
639
640 /**
641  * Given a tree picker item, return collection uuid and path
642  *   if the item represents a valid target/destination location
643  */
644 export type FileOperationLocation = {
645     name: string;
646     uuid: string;
647     pdh?: string;
648     subpath: string;
649 }
650 export const getFileOperationLocation = (item: ProjectsTreePickerItem) =>
651     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<FileOperationLocation | undefined> => {
652         if ('kind' in item && item.kind === ResourceKind.COLLECTION) {
653             return {
654                 name: item.name,
655                 uuid: item.uuid,
656                 pdh: item.portableDataHash,
657                 subpath: '/',
658             };
659         } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
660             const uuid = getCollectionResourceCollectionUuid(item.id);
661             if (uuid) {
662                 const collection = getResource<CollectionResource>(uuid)(getState().resources);
663                 if (collection) {
664                     const itemPath = [item.path, item.name].join('/');
665
666                     return {
667                         name: item.name,
668                         uuid,
669                         pdh: collection.portableDataHash,
670                         subpath: itemPath,
671                     };
672                 }
673             }
674         }
675         return undefined;
676     };
677
678 /**
679  * Create an expanded tree picker subtree from array of nested projects/collection
680  *   First item is assumed to be root and gets empty parent id
681  *   Nodes must be sorted from top down to prevent orphaned nodes
682  */
683 export const createInitialPickerTree = (sortedAncestors: Array<GroupResource | CollectionResource>, tailUuid: string, initialTree: Tree<GroupResource | CollectionResource>) => {
684     return sortedAncestors
685         .reduce((tree, item, index) => {
686             if (getNode(item.uuid)(tree)) {
687                 return tree;
688             } else {
689                 return setNode({
690                     children: [],
691                     id: item.uuid,
692                     parent: index === 0 ? '' : item.ownerUuid,
693                     value: item,
694                     active: false,
695                     selected: false,
696                     expanded: false,
697                     status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
698                 })(tree);
699             }
700         }, initialTree);
701 };
702
703 export const fileOperationLocationToPickerId = (location: FileOperationLocation): string => {
704     let id = location.uuid;
705     if (location.subpath.length && location.subpath !== '/') {
706         id = id + location.subpath;
707     }
708     return id;
709 }