20225: Add subdirectory selection support to directory array picker
[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             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 export const loadProject = (params: LoadProjectParamsWithId) =>
149     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
150         const {
151             id,
152             pickerId,
153             includeCollections = false,
154             includeDirectories = false,
155             includeFiles = false,
156             includeFilterGroups = false,
157             loadShared = false,
158             options,
159             searchProjects = false
160         } = params;
161
162         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
163
164         let filterB = new FilterBuilder();
165
166         filterB = (includeCollections && !searchProjects)
167             ? filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
168             : filterB.addIsA('uuid', [ResourceKind.PROJECT]);
169
170         const state = getState();
171
172         if (state.treePickerSearch.collectionFilterValues[pickerId]) {
173             filterB = filterB.addFullTextSearch(state.treePickerSearch.collectionFilterValues[pickerId], 'collections');
174         } else {
175             filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
176         }
177
178         if (searchProjects && state.treePickerSearch.projectSearchValues[pickerId]) {
179             filterB = filterB.addFullTextSearch(state.treePickerSearch.projectSearchValues[pickerId], 'groups');
180         }
181
182         const filters = filterB.getFilters();
183
184         const itemLimit = 200;
185
186         const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit });
187         dispatch<any>(updateResources(items));
188
189         if (itemsAvailable > itemLimit) {
190             items.push({
191                 uuid: "more-items-available",
192                 kind: ResourceKind.WORKFLOW,
193                 name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`,
194                 description: "",
195                 definition: "",
196                 ownerUuid: "",
197                 createdAt: "",
198                 modifiedByClientUuid: "",
199                 modifiedByUserUuid: "",
200                 modifiedAt: "",
201                 href: "",
202                 etag: ""
203             });
204         }
205
206         dispatch<any>(receiveTreePickerData<GroupContentsResource>({
207             id,
208             pickerId,
209             data: items.filter((item) => {
210                 if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
211                     return false;
212                 }
213
214                 if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
215                     return false;
216                 }
217
218                 return true;
219             }),
220             extractNodeData: item => (
221                 item.uuid === "more-items-available" ?
222                     {
223                         id: item.uuid,
224                         value: item,
225                         status: TreeNodeStatus.LOADED
226                     }
227                     : {
228                         id: item.uuid,
229                         value: item,
230                         status: item.kind === ResourceKind.PROJECT
231                             ? TreeNodeStatus.INITIAL
232                             : includeDirectories || includeFiles
233                                 ? TreeNodeStatus.INITIAL
234                                 : TreeNodeStatus.LOADED
235                     }),
236         }));
237     };
238
239 export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) =>
240     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
241         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
242
243         const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
244         if (picker) {
245
246             const node = getNode(id)(picker);
247             if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
248                 const files = (await services.collectionService.files(node.value.uuid))
249                     .filter((file) => (
250                         (includeFiles) ||
251                         (includeDirectories && file.type === CollectionFileType.DIRECTORY)
252                     ));
253                 const tree = createCollectionFilesTree(files);
254                 const sorted = sortFilesTree(tree);
255                 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
256
257                 // await tree modifications so that consumers can guarantee node presence
258                 await dispatch(
259                     treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
260                         id,
261                         pickerId,
262                         subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
263                     }));
264
265                 // Expand collection root node
266                 dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
267             }
268         }
269     };
270
271 export const HOME_PROJECT_ID = 'Home Projects';
272 export const initUserProject = (pickerId: string) =>
273     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
274         const uuid = getUserUuid(getState());
275         if (uuid) {
276             dispatch(receiveTreePickerData({
277                 id: '',
278                 pickerId,
279                 data: [{ uuid, name: HOME_PROJECT_ID }],
280                 extractNodeData: value => ({
281                     id: value.uuid,
282                     status: TreeNodeStatus.INITIAL,
283                     value,
284                 }),
285             }));
286         }
287     };
288 export const loadUserProject = (pickerId: string, includeCollections = false, includeDirectories = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
289     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
290         const uuid = getUserUuid(getState());
291         if (uuid) {
292             dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
293         }
294     };
295
296 export const SHARED_PROJECT_ID = 'Shared with me';
297 export const initSharedProject = (pickerId: string) =>
298     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
299         dispatch(receiveTreePickerData({
300             id: '',
301             pickerId,
302             data: [{ uuid: SHARED_PROJECT_ID, name: SHARED_PROJECT_ID }],
303             extractNodeData: value => ({
304                 id: value.uuid,
305                 status: TreeNodeStatus.INITIAL,
306                 value,
307             }),
308         }));
309     };
310
311 type PickerItemPreloadData = {
312     itemId: string;
313     mainItemUuid: string;
314     ancestors: (GroupResource | CollectionResource)[];
315     isHomeProjectItem: boolean;
316 }
317
318 type PickerTreePreloadData = {
319     tree: Tree<GroupResource | CollectionResource>;
320     pickerTreeId: string;
321     pickerTreeRootUuid: string;
322 };
323
324 export const loadInitialValue = (pickerItemIds: string[], pickerId: string, includeDirectories: boolean, includeFiles: boolean, multi: boolean,) =>
325     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
326         const homeUuid = getUserUuid(getState());
327
328         // Request ancestor trees in paralell and save home project status
329         const pickerItemsData: PickerItemPreloadData[] = await Promise.allSettled(pickerItemIds.map(async itemId => {
330             const mainItemUuid = itemId.includes('/') ? itemId.split('/')[0] : itemId;
331             const ancestors = (await services.ancestorsService.ancestors(mainItemUuid, ''))
332             .filter(item =>
333                 item.kind === ResourceKind.GROUP ||
334                 item.kind === ResourceKind.COLLECTION
335             ) as (GroupResource | CollectionResource)[];
336
337             const isHomeProjectItem = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid));
338
339             return {
340                 itemId,
341                 mainItemUuid,
342                 ancestors,
343                 isHomeProjectItem,
344             };
345         })).then((res) => {
346             // Show toast if any selections failed to restore
347             if (res.find((promiseResult): promiseResult is PromiseRejectedResult => (promiseResult.status === 'rejected'))) {
348                 dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Some selections failed to load and were removed`, kind: SnackbarKind.ERROR }));
349             }
350             // Filter out any failed promises and map to resulting preload data with ancestors
351             return res.filter((promiseResult): promiseResult is PromiseFulfilledResult<PickerItemPreloadData> => (
352                 promiseResult.status === 'fulfilled'
353             )).map(res => res.value)
354         });
355
356         // Group items to preload / ancestor data by home/shared picker and create initial Trees to preload
357         const initialTreePreloadData: PickerTreePreloadData[] = [
358             pickerItemsData.filter((item) => item.isHomeProjectItem),
359             pickerItemsData.filter((item) => !item.isHomeProjectItem),
360         ]
361             .filter((items) => items.length > 0)
362             .map((itemGroup) =>
363                 itemGroup.reduce(
364                     (preloadTree, itemData) => ({
365                         tree: createInitialPickerTree(
366                             itemData.ancestors,
367                             itemData.mainItemUuid,
368                             preloadTree.tree
369                         ),
370                         pickerTreeId: getPickerItemTreeId(itemData, homeUuid, pickerId),
371                         pickerTreeRootUuid: getPickerItemRootUuid(itemData, homeUuid),
372                     }),
373                     {
374                         tree: createTree<GroupResource | CollectionResource>(),
375                         pickerTreeId: '',
376                         pickerTreeRootUuid: '',
377                     } as PickerTreePreloadData
378                 )
379             );
380
381         // Load initial trees into corresponding picker store
382         await Promise.all(initialTreePreloadData.map(preloadTree => (
383             dispatch(
384                 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
385                     id: preloadTree.pickerTreeRootUuid,
386                     pickerId: preloadTree.pickerTreeId,
387                     subtree: preloadTree.tree,
388                 })
389             )
390         )));
391
392         // Await loading collection before attempting to select items
393         await Promise.all(pickerItemsData.map(async itemData => {
394             const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
395
396             // Selected item resides in collection subpath
397             if (itemData.itemId.includes('/')) {
398                 // Load collection into tree
399                 // loadCollection includes more than dispatched actions and must be awaited
400                 await dispatch(loadCollection(itemData.mainItemUuid, pickerTreeId, includeDirectories, includeFiles));
401             }
402             // Expand nodes down to destination
403             dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE_ANCESTORS({ id: itemData.itemId, pickerId: pickerTreeId }));
404         }));
405
406         // Select or activate nodes
407         pickerItemsData.forEach(itemData => {
408             const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
409
410             if (multi) {
411                 dispatch(treePickerActions.SELECT_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId, cascade: false}));
412             } else {
413                 dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId }));
414             }
415         });
416
417         // Refresh triggers loading in all adjacent items that were not included in the ancestor tree
418         await initialTreePreloadData.map(preloadTree => dispatch(treePickerSearchActions.REFRESH_TREE_PICKER({ pickerId: preloadTree.pickerTreeId })));
419     }
420
421 const getPickerItemTreeId = (itemData: PickerItemPreloadData, homeUuid: string | undefined, pickerId: string) => {
422     const { home, shared } = getProjectsTreePickerIds(pickerId);
423     return ((itemData.isHomeProjectItem && homeUuid) ? home : shared);
424 };
425
426 const getPickerItemRootUuid = (itemData: PickerItemPreloadData, homeUuid: string | undefined) => {
427     return (itemData.isHomeProjectItem && homeUuid) ? homeUuid : SHARED_PROJECT_ID;
428 };
429
430 export const FAVORITES_PROJECT_ID = 'Favorites';
431 export const initFavoritesProject = (pickerId: string) =>
432     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
433         dispatch(receiveTreePickerData({
434             id: '',
435             pickerId,
436             data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
437             extractNodeData: value => ({
438                 id: value.uuid,
439                 status: TreeNodeStatus.INITIAL,
440                 value,
441             }),
442         }));
443     };
444
445 export const PUBLIC_FAVORITES_PROJECT_ID = 'Public Favorites';
446 export const initPublicFavoritesProject = (pickerId: string) =>
447     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
448         dispatch(receiveTreePickerData({
449             id: '',
450             pickerId,
451             data: [{ uuid: PUBLIC_FAVORITES_PROJECT_ID, name: PUBLIC_FAVORITES_PROJECT_ID }],
452             extractNodeData: value => ({
453                 id: value.uuid,
454                 status: TreeNodeStatus.INITIAL,
455                 value,
456             }),
457         }));
458     };
459
460 export const SEARCH_PROJECT_ID = 'Search all Projects';
461 export const initSearchProject = (pickerId: string) =>
462     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
463         dispatch(receiveTreePickerData({
464             id: '',
465             pickerId,
466             data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
467             extractNodeData: value => ({
468                 id: value.uuid,
469                 status: TreeNodeStatus.INITIAL,
470                 value,
471             }),
472         }));
473     };
474
475
476 interface LoadFavoritesProjectParams {
477     pickerId: string;
478     includeCollections?: boolean;
479     includeDirectories?: boolean;
480     includeFiles?: boolean;
481     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
482 }
483
484 export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
485     options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) =>
486     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
487         const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
488         const uuid = getUserUuid(getState());
489         if (uuid) {
490             const filters = pipe(
491                 (fb: FilterBuilder) => includeCollections
492                     ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
493                     : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
494                 fb => fb.getFilters(),
495             )(new FilterBuilder());
496
497             const { items } = await services.favoriteService.list(uuid, { filters }, options.showOnlyOwned);
498
499             dispatch<any>(receiveTreePickerData<GroupContentsResource>({
500                 id: 'Favorites',
501                 pickerId,
502                 data: items.filter((item) => {
503                     if (options.showOnlyWritable && !(item as GroupResource).canWrite) {
504                         return false;
505                     }
506
507                     if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
508                         return false;
509                     }
510
511                     return true;
512                 }),
513                 extractNodeData: item => ({
514                     id: item.uuid,
515                     value: item,
516                     status: item.kind === ResourceKind.PROJECT
517                         ? TreeNodeStatus.INITIAL
518                         : includeDirectories || includeFiles
519                             ? TreeNodeStatus.INITIAL
520                             : TreeNodeStatus.LOADED
521                 }),
522             }));
523         }
524     };
525
526 export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =>
527     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
528         const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
529         const uuidPrefix = getState().auth.config.uuidPrefix;
530         const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
531
532         const filters = pipe(
533             (fb: FilterBuilder) => includeCollections
534                 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
535                 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
536             fb => fb
537                 .addEqual('link_class', LinkClass.STAR)
538                 .addEqual('owner_uuid', publicProjectUuid)
539                 .getFilters(),
540         )(new FilterBuilder());
541
542         const { items } = await services.linkService.list({ filters });
543
544         dispatch<any>(receiveTreePickerData<LinkResource>({
545             id: 'Public Favorites',
546             pickerId,
547             data: items.filter(item => {
548                 if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
549                     return false;
550                 }
551
552                 return true;
553             }),
554             extractNodeData: item => ({
555                 id: item.headUuid,
556                 value: item,
557                 status: item.headKind === ResourceKind.PROJECT
558                     ? TreeNodeStatus.INITIAL
559                     : includeDirectories || includeFiles
560                         ? TreeNodeStatus.INITIAL
561                         : TreeNodeStatus.LOADED
562             }),
563         }));
564     };
565
566 export const receiveTreePickerProjectsData = (id: string, projects: ProjectResource[], pickerId: string) =>
567     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
568         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
569             id,
570             nodes: projects.map(project => initTreeNode({ id: project.uuid, value: project })),
571             pickerId,
572         }));
573
574         dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
575     };
576
577 export const loadProjectTreePickerProjects = (id: string) =>
578     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
579         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PROJECTS }));
580
581
582         const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
583         const { items } = await services.projectService.list(buildParams(ownerUuid));
584
585         dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
586     };
587
588 export const loadFavoriteTreePickerProjects = (id: string) =>
589     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
590         const parentId = getUserUuid(getState()) || '';
591
592         if (id === '') {
593             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.FAVORITES }));
594             const { items } = await services.favoriteService.list(parentId);
595             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
596         } else {
597             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.FAVORITES }));
598             const { items } = await services.projectService.list(buildParams(id));
599             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.FAVORITES));
600         }
601
602     };
603
604 export const loadPublicFavoriteTreePickerProjects = (id: string) =>
605     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
606         const parentId = getUserUuid(getState()) || '';
607
608         if (id === '') {
609             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.PUBLIC_FAVORITES }));
610             const { items } = await services.favoriteService.list(parentId);
611             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.PUBLIC_FAVORITES));
612         } else {
613             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PUBLIC_FAVORITES }));
614             const { items } = await services.projectService.list(buildParams(id));
615             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PUBLIC_FAVORITES));
616         }
617
618     };
619
620 const buildParams = (ownerUuid: string) => {
621     return {
622         filters: new FilterBuilder()
623             .addEqual('owner_uuid', ownerUuid)
624             .getFilters(),
625         order: new OrderBuilder<ProjectResource>()
626             .addAsc('name')
627             .getOrder()
628     };
629 };
630
631 /**
632  * Given a tree picker item, return collection uuid and path
633  *   if the item represents a valid target/destination location
634  */
635 export type FileOperationLocation = {
636     name: string;
637     uuid: string;
638     pdh?: string;
639     subpath: string;
640 }
641 export const getFileOperationLocation = (item: ProjectsTreePickerItem) =>
642     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<FileOperationLocation | undefined> => {
643         if ('kind' in item && item.kind === ResourceKind.COLLECTION) {
644             return {
645                 name: item.name,
646                 uuid: item.uuid,
647                 pdh: item.portableDataHash,
648                 subpath: '/',
649             };
650         } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
651             const uuid = getCollectionResourceCollectionUuid(item.id);
652             if (uuid) {
653                 const collection = getResource<CollectionResource>(uuid)(getState().resources);
654                 if (collection) {
655                     const itemPath = [item.path, item.name].join('/');
656
657                     return {
658                         name: item.name,
659                         uuid,
660                         pdh: collection.portableDataHash,
661                         subpath: itemPath,
662                     };
663                 }
664             }
665         }
666         return undefined;
667     };
668
669 /**
670  * Create an expanded tree picker subtree from array of nested projects/collection
671  *   First item is assumed to be root and gets empty parent id
672  *   Nodes must be sorted from top down to prevent orphaned nodes
673  */
674 export const createInitialPickerTree = (sortedAncestors: Array<GroupResource | CollectionResource>, tailUuid: string, initialTree: Tree<GroupResource | CollectionResource>) => {
675     return sortedAncestors
676         .reduce((tree, item, index) => {
677             if (getNode(item.uuid)(tree)) {
678                 return tree;
679             } else {
680                 return setNode({
681                     children: [],
682                     id: item.uuid,
683                     parent: index === 0 ? '' : item.ownerUuid,
684                     value: item,
685                     active: false,
686                     selected: false,
687                     expanded: false,
688                     status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
689                 })(tree);
690             }
691         }, initialTree);
692 };
693
694 export const fileOperationLocationToPickerId = (location: FileOperationLocation): string => {
695     let id = location.uuid;
696     if (location.subpath.length && location.subpath !== '/') {
697         id = id + location.subpath;
698     }
699     return id;
700 }