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