20225: Add subdirectory selection support to directory input
[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
29 export const treePickerActions = unionize({
30     LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
31     LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array<TreeNode<any>>, pickerId: string }>(),
32     APPEND_TREE_PICKER_NODE_SUBTREE: ofType<{ id: string, subtree: Tree<any>, pickerId: string }>(),
33     TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string, pickerId: string }>(),
34     EXPAND_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
35     ACTIVATE_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string, relatedTreePickers?: string[] }>(),
36     DEACTIVATE_TREE_PICKER_NODE: ofType<{ pickerId: string }>(),
37     TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string }>(),
38     SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(),
39     DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(),
40     EXPAND_TREE_PICKER_NODES: ofType<{ ids: string[], pickerId: string }>(),
41     RESET_TREE_PICKER: ofType<{ pickerId: string }>()
42 });
43
44 export type TreePickerAction = UnionOf<typeof treePickerActions>;
45
46 export interface LoadProjectParams {
47     includeCollections?: boolean;
48     includeDirectories?: boolean;
49     includeFiles?: boolean;
50     includeFilterGroups?: boolean;
51     options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; };
52 }
53
54 export const treePickerSearchActions = unionize({
55     SET_TREE_PICKER_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(),
56     SET_TREE_PICKER_COLLECTION_FILTER: ofType<{ pickerId: string, collectionFilterValue: string }>(),
57     SET_TREE_PICKER_LOAD_PARAMS: ofType<{ pickerId: string, params: LoadProjectParams }>(),
58     REFRESH_TREE_PICKER: ofType<{ pickerId: string }>(),
59 });
60
61 export type TreePickerSearchAction = UnionOf<typeof treePickerSearchActions>;
62
63 export const getProjectsTreePickerIds = (pickerId: string) => ({
64     home: `${pickerId}_home`,
65     shared: `${pickerId}_shared`,
66     favorites: `${pickerId}_favorites`,
67     publicFavorites: `${pickerId}_publicFavorites`,
68     search: `${pickerId}_search`,
69 });
70
71 export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
72     pipe(
73         () => values(getProjectsTreePickerIds(pickerId)),
74
75         ids => ids
76             .map(id => getTreePicker<Value>(id)(state)),
77
78         trees => trees
79             .map(getNodeDescendants(''))
80             .reduce((allNodes, nodes) => allNodes.concat(nodes), []),
81
82         allNodes => allNodes
83             .reduce((map, node) =>
84                 filter(node)
85                     ? map.set(node.id, node)
86                     : map, new Map<string, TreeNode<Value>>())
87             .values(),
88
89         uniqueNodes => Array.from(uniqueNodes),
90     )();
91 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
92     getAllNodes<Value>(pickerId, node => node.selected)(state);
93
94 export const initProjectsTreePicker = (pickerId: string, selectedItemUuid?: string) =>
95     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
96         const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
97         dispatch<any>(initUserProject(home));
98         dispatch<any>(initSharedProject(shared));
99         dispatch<any>(initFavoritesProject(favorites));
100         dispatch<any>(initPublicFavoritesProject(publicFavorites));
101         dispatch<any>(initSearchProject(search));
102
103         if (selectedItemUuid) {
104             dispatch<any>(loadInitialValue(selectedItemUuid, pickerId));
105         }
106     };
107
108 interface ReceiveTreePickerDataParams<T> {
109     data: T[];
110     extractNodeData: (value: T) => { id: string, value: T, status?: TreeNodeStatus };
111     id: string;
112     pickerId: string;
113 }
114
115 export const receiveTreePickerData = <T>(params: ReceiveTreePickerDataParams<T>) =>
116     (dispatch: Dispatch) => {
117         const { data, extractNodeData, id, pickerId, } = params;
118         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
119             id,
120             nodes: data.map(item => initTreeNode(extractNodeData(item))),
121             pickerId,
122         }));
123         dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
124     };
125
126 interface LoadProjectParamsWithId extends LoadProjectParams {
127     id: string;
128     pickerId: string;
129     loadShared?: boolean;
130     searchProjects?: boolean;
131 }
132
133 export const loadProject = (params: LoadProjectParamsWithId) =>
134     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
135         const {
136             id,
137             pickerId,
138             includeCollections = false,
139             includeDirectories = false,
140             includeFiles = false,
141             includeFilterGroups = false,
142             loadShared = false,
143             options,
144             searchProjects = false
145         } = params;
146
147         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
148
149         let filterB = new FilterBuilder();
150
151         filterB = (includeCollections && !searchProjects)
152             ? filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
153             : filterB.addIsA('uuid', [ResourceKind.PROJECT]);
154
155         const state = getState();
156
157         if (state.treePickerSearch.collectionFilterValues[pickerId]) {
158             filterB = filterB.addFullTextSearch(state.treePickerSearch.collectionFilterValues[pickerId], 'collections');
159         } else {
160             filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
161         }
162
163         if (searchProjects && state.treePickerSearch.projectSearchValues[pickerId]) {
164             filterB = filterB.addFullTextSearch(state.treePickerSearch.projectSearchValues[pickerId], 'groups');
165         }
166
167         const filters = filterB.getFilters();
168
169         const itemLimit = 200;
170
171         const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit });
172         dispatch<any>(updateResources(items));
173
174         if (itemsAvailable > itemLimit) {
175             items.push({
176                 uuid: "more-items-available",
177                 kind: ResourceKind.WORKFLOW,
178                 name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`,
179                 description: "",
180                 definition: "",
181                 ownerUuid: "",
182                 createdAt: "",
183                 modifiedByClientUuid: "",
184                 modifiedByUserUuid: "",
185                 modifiedAt: "",
186                 href: "",
187                 etag: ""
188             });
189         }
190
191         dispatch<any>(receiveTreePickerData<GroupContentsResource>({
192             id,
193             pickerId,
194             data: items.filter((item) => {
195                 if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
196                     return false;
197                 }
198
199                 if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
200                     return false;
201                 }
202
203                 return true;
204             }),
205             extractNodeData: item => (
206                 item.uuid === "more-items-available" ?
207                     {
208                         id: item.uuid,
209                         value: item,
210                         status: TreeNodeStatus.LOADED
211                     }
212                     : {
213                         id: item.uuid,
214                         value: item,
215                         status: item.kind === ResourceKind.PROJECT
216                             ? TreeNodeStatus.INITIAL
217                             : includeDirectories || includeFiles
218                                 ? TreeNodeStatus.INITIAL
219                                 : TreeNodeStatus.LOADED
220                     }),
221         }));
222     };
223
224 export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) =>
225     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
226         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
227
228         const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
229         if (picker) {
230
231             const node = getNode(id)(picker);
232             if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
233                 const files = (await services.collectionService.files(node.value.uuid))
234                     .filter((file) => (
235                         (includeFiles) ||
236                         (includeDirectories && file.type === CollectionFileType.DIRECTORY)
237                     ));
238                 const tree = createCollectionFilesTree(files);
239                 const sorted = sortFilesTree(tree);
240                 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
241
242                 dispatch(
243                     treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
244                         id,
245                         pickerId,
246                         subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
247                     }));
248
249                 dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
250             }
251         }
252     };
253
254 export const HOME_PROJECT_ID = 'Home Projects';
255 export const initUserProject = (pickerId: string) =>
256     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
257         const uuid = getUserUuid(getState());
258         if (uuid) {
259             dispatch(receiveTreePickerData({
260                 id: '',
261                 pickerId,
262                 data: [{ uuid, name: HOME_PROJECT_ID }],
263                 extractNodeData: value => ({
264                     id: value.uuid,
265                     status: TreeNodeStatus.INITIAL,
266                     value,
267                 }),
268             }));
269         }
270     };
271 export const loadUserProject = (pickerId: string, includeCollections = false, includeDirectories = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
272     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
273         const uuid = getUserUuid(getState());
274         if (uuid) {
275             dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
276         }
277     };
278
279 export const SHARED_PROJECT_ID = 'Shared with me';
280 export const initSharedProject = (pickerId: string) =>
281     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
282         dispatch(receiveTreePickerData({
283             id: '',
284             pickerId,
285             data: [{ uuid: SHARED_PROJECT_ID, name: SHARED_PROJECT_ID }],
286             extractNodeData: value => ({
287                 id: value.uuid,
288                 status: TreeNodeStatus.INITIAL,
289                 value,
290             }),
291         }));
292     };
293
294 export const loadInitialValue = (initialValue: string, pickerId: string) =>
295     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
296         const { home, shared } = getProjectsTreePickerIds(pickerId);
297         const homeUuid = getUserUuid(getState());
298         const ancestors = (await services.ancestorsService.ancestors(initialValue, ''))
299             .filter(item =>
300                 item.kind === ResourceKind.GROUP ||
301                 item.kind === ResourceKind.COLLECTION
302             ) as (GroupResource | CollectionResource)[];
303
304         if (ancestors.length) {
305             const isUserHomeProject = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid));
306             const pickerTreeId = isUserHomeProject ? home : shared;
307             const pickerTreeRootUuid: string = (homeUuid && isUserHomeProject) ? homeUuid : SHARED_PROJECT_ID;
308
309             ancestors[0].ownerUuid = '';
310             const tree = createInitialLocationTree(ancestors, initialValue);
311             dispatch(
312                 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
313                     id: pickerTreeRootUuid,
314                     pickerId: pickerTreeId,
315                     subtree: tree
316                 }));
317             dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: initialValue, pickerId: pickerTreeId }));
318             dispatch(treePickerSearchActions.REFRESH_TREE_PICKER({ pickerId: pickerTreeId }));
319         }
320
321     }
322
323 export const FAVORITES_PROJECT_ID = 'Favorites';
324 export const initFavoritesProject = (pickerId: string) =>
325     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
326         dispatch(receiveTreePickerData({
327             id: '',
328             pickerId,
329             data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
330             extractNodeData: value => ({
331                 id: value.uuid,
332                 status: TreeNodeStatus.INITIAL,
333                 value,
334             }),
335         }));
336     };
337
338 export const PUBLIC_FAVORITES_PROJECT_ID = 'Public Favorites';
339 export const initPublicFavoritesProject = (pickerId: string) =>
340     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
341         dispatch(receiveTreePickerData({
342             id: '',
343             pickerId,
344             data: [{ uuid: PUBLIC_FAVORITES_PROJECT_ID, name: PUBLIC_FAVORITES_PROJECT_ID }],
345             extractNodeData: value => ({
346                 id: value.uuid,
347                 status: TreeNodeStatus.INITIAL,
348                 value,
349             }),
350         }));
351     };
352
353 export const SEARCH_PROJECT_ID = 'Search all Projects';
354 export const initSearchProject = (pickerId: string) =>
355     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
356         dispatch(receiveTreePickerData({
357             id: '',
358             pickerId,
359             data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
360             extractNodeData: value => ({
361                 id: value.uuid,
362                 status: TreeNodeStatus.INITIAL,
363                 value,
364             }),
365         }));
366     };
367
368
369 interface LoadFavoritesProjectParams {
370     pickerId: string;
371     includeCollections?: boolean;
372     includeDirectories?: boolean;
373     includeFiles?: boolean;
374     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
375 }
376
377 export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
378     options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) =>
379     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
380         const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
381         const uuid = getUserUuid(getState());
382         if (uuid) {
383             const filters = pipe(
384                 (fb: FilterBuilder) => includeCollections
385                     ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
386                     : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
387                 fb => fb.getFilters(),
388             )(new FilterBuilder());
389
390             const { items } = await services.favoriteService.list(uuid, { filters }, options.showOnlyOwned);
391
392             dispatch<any>(receiveTreePickerData<GroupContentsResource>({
393                 id: 'Favorites',
394                 pickerId,
395                 data: items.filter((item) => {
396                     if (options.showOnlyWritable && !(item as GroupResource).canWrite) {
397                         return false;
398                     }
399
400                     if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
401                         return false;
402                     }
403
404                     return true;
405                 }),
406                 extractNodeData: item => ({
407                     id: item.uuid,
408                     value: item,
409                     status: item.kind === ResourceKind.PROJECT
410                         ? TreeNodeStatus.INITIAL
411                         : includeDirectories || includeFiles
412                             ? TreeNodeStatus.INITIAL
413                             : TreeNodeStatus.LOADED
414                 }),
415             }));
416         }
417     };
418
419 export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =>
420     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
421         const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
422         const uuidPrefix = getState().auth.config.uuidPrefix;
423         const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
424
425         const filters = pipe(
426             (fb: FilterBuilder) => includeCollections
427                 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
428                 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
429             fb => fb
430                 .addEqual('link_class', LinkClass.STAR)
431                 .addEqual('owner_uuid', publicProjectUuid)
432                 .getFilters(),
433         )(new FilterBuilder());
434
435         const { items } = await services.linkService.list({ filters });
436
437         dispatch<any>(receiveTreePickerData<LinkResource>({
438             id: 'Public Favorites',
439             pickerId,
440             data: items.filter(item => {
441                 if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
442                     return false;
443                 }
444
445                 return true;
446             }),
447             extractNodeData: item => ({
448                 id: item.headUuid,
449                 value: item,
450                 status: item.headKind === ResourceKind.PROJECT
451                     ? TreeNodeStatus.INITIAL
452                     : includeDirectories || includeFiles
453                         ? TreeNodeStatus.INITIAL
454                         : TreeNodeStatus.LOADED
455             }),
456         }));
457     };
458
459 export const receiveTreePickerProjectsData = (id: string, projects: ProjectResource[], pickerId: string) =>
460     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
461         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
462             id,
463             nodes: projects.map(project => initTreeNode({ id: project.uuid, value: project })),
464             pickerId,
465         }));
466
467         dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
468     };
469
470 export const loadProjectTreePickerProjects = (id: string) =>
471     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
472         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PROJECTS }));
473
474
475         const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
476         const { items } = await services.projectService.list(buildParams(ownerUuid));
477
478         dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
479     };
480
481 export const loadFavoriteTreePickerProjects = (id: string) =>
482     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
483         const parentId = getUserUuid(getState()) || '';
484
485         if (id === '') {
486             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.FAVORITES }));
487             const { items } = await services.favoriteService.list(parentId);
488             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
489         } else {
490             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.FAVORITES }));
491             const { items } = await services.projectService.list(buildParams(id));
492             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.FAVORITES));
493         }
494
495     };
496
497 export const loadPublicFavoriteTreePickerProjects = (id: string) =>
498     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
499         const parentId = getUserUuid(getState()) || '';
500
501         if (id === '') {
502             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.PUBLIC_FAVORITES }));
503             const { items } = await services.favoriteService.list(parentId);
504             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.PUBLIC_FAVORITES));
505         } else {
506             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PUBLIC_FAVORITES }));
507             const { items } = await services.projectService.list(buildParams(id));
508             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PUBLIC_FAVORITES));
509         }
510
511     };
512
513 const buildParams = (ownerUuid: string) => {
514     return {
515         filters: new FilterBuilder()
516             .addEqual('owner_uuid', ownerUuid)
517             .getFilters(),
518         order: new OrderBuilder<ProjectResource>()
519             .addAsc('name')
520             .getOrder()
521     };
522 };
523
524 /**
525  * Given a tree picker item, return collection uuid and path
526  *   if the item represents a valid target/destination location
527  */
528 export type FileOperationLocation = {
529     name: string;
530     uuid: string;
531     pdh?: string;
532     subpath: string;
533 }
534 export const getFileOperationLocation = (item: ProjectsTreePickerItem) =>
535     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<FileOperationLocation | undefined> => {
536         if ('kind' in item && item.kind === ResourceKind.COLLECTION) {
537             return {
538                 name: item.name,
539                 uuid: item.uuid,
540                 pdh: item.portableDataHash,
541                 subpath: '/',
542             };
543         } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
544             const uuid = getCollectionResourceCollectionUuid(item.id);
545             if (uuid) {
546                 const collection = getResource<CollectionResource>(uuid)(getState().resources);
547                 if (collection) {
548                     const itemPath = [item.path, item.name].join('/');
549
550                     return {
551                         name: item.name,
552                         uuid,
553                         pdh: collection.portableDataHash,
554                         subpath: itemPath,
555                     };
556                 }
557             }
558         }
559         return undefined;
560     };
561
562 /**
563  * Create an expanded tree picker subtree from array of nested projects/collection
564  *   Assumes the root item of the subtree already has an empty string ownerUuid
565  */
566 export const createInitialLocationTree = (data: Array<GroupResource | CollectionResource>, tailUuid: string) => {
567     return data
568         .reduce((tree, item) => setNode({
569             children: [],
570             id: item.uuid,
571             parent: item.ownerUuid,
572             value: item,
573             active: false,
574             selected: false,
575             expanded: false,
576             status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
577         })(tree), createTree<GroupResource | CollectionResource>());
578 };