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