Merge branch '21999-packer-fixes'
[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 { Resource, ResourceKind, ResourceObjectType, extractUuidObjectType, COLLECTION_PDH_REGEX } from 'models/resource';
15 import { GroupContentsResource, GroupContentsIncludedResource } from 'services/groups-service/groups-service';
16 import { getTreePicker, TreePicker, TreeItemWeight, TreeItemWithWeight } 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 { UserResource } from 'models/user';
21 import { mapTree } from '../../models/tree';
22 import { LinkResource, LinkClass } from "models/link";
23 import { mapTreeValues } from "models/tree";
24 import { sortFilesTree } from "services/collection-service/collection-service-files-response";
25 import { GroupClass, GroupResource } from "models/group";
26 import { CollectionResource } from "models/collection";
27 import { getResource } from "store/resources/resources";
28 import { updateResources } from "store/resources/resources-actions";
29 import { SnackbarKind, snackbarActions } from "store/snackbar/snackbar-actions";
30
31 export const treePickerActions = unionize({
32     LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
33     LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array<TreeNode<any>>, pickerId: string }>(),
34     APPEND_TREE_PICKER_NODE_SUBTREE: ofType<{ id: string, subtree: Tree<any>, pickerId: string }>(),
35     TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string, pickerId: string }>(),
36     EXPAND_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
37     EXPAND_TREE_PICKER_NODE_ANCESTORS: ofType<{ id: string, pickerId: string }>(),
38     ACTIVATE_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string, relatedTreePickers?: string[] }>(),
39     DEACTIVATE_TREE_PICKER_NODE: ofType<{ pickerId: string }>(),
40     TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string, cascade: boolean }>(),
41     SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(),
42     DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(),
43     EXPAND_TREE_PICKER_NODES: ofType<{ ids: string[], pickerId: string }>(),
44     RESET_TREE_PICKER: ofType<{ pickerId: string }>()
45 });
46
47 export type TreePickerAction = UnionOf<typeof treePickerActions>;
48
49 export interface LoadProjectParams {
50     includeCollections?: boolean;
51     includeDirectories?: boolean;
52     includeFiles?: boolean;
53     includeFilterGroups?: boolean;
54     options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; };
55 }
56
57 export const treePickerSearchActions = unionize({
58     SET_TREE_PICKER_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(),
59     SET_TREE_PICKER_COLLECTION_FILTER: ofType<{ pickerId: string, collectionFilterValue: string }>(),
60     SET_TREE_PICKER_LOAD_PARAMS: ofType<{ pickerId: string, params: LoadProjectParams }>(),
61     REFRESH_TREE_PICKER: ofType<{ pickerId: string }>(),
62 });
63
64 export type TreePickerSearchAction = UnionOf<typeof treePickerSearchActions>;
65
66 export const getProjectsTreePickerIds = (pickerId: string) => ({
67     home: `${pickerId}_home`,
68     shared: `${pickerId}_shared`,
69     favorites: `${pickerId}_favorites`,
70     publicFavorites: `${pickerId}_publicFavorites`,
71     search: `${pickerId}_search`,
72 });
73
74 export const SEARCH_PROJECT_ID_PREFIX = "search-";
75
76 export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
77     pipe(
78         () => values(getProjectsTreePickerIds(pickerId)),
79
80         ids => ids
81             .map(id => getTreePicker<Value>(id)(state)),
82
83         trees => trees
84             .map(getNodeDescendants(''))
85             .reduce((allNodes, nodes) => allNodes.concat(nodes), []),
86
87         allNodes => allNodes
88             .reduce((map, node) =>
89                 filter(node)
90                     ? map.set(node.id, node)
91                     : map, new Map<string, TreeNode<Value>>())
92             .values(),
93
94         uniqueNodes => Array.from(uniqueNodes),
95     )();
96 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
97     getAllNodes<Value>(pickerId, node => node.selected)(state);
98
99 interface TreePickerPreloadParams {
100     selectedItemUuids: string[];
101     includeDirectories: boolean;
102     includeFiles: boolean;
103     multi: boolean;
104 }
105
106 export const initProjectsTreePicker = (pickerId: string, preloadParams?: TreePickerPreloadParams) =>
107     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
108         const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
109         dispatch<any>(initUserProject(home));
110         dispatch<any>(initSharedProject(shared));
111         dispatch<any>(initFavoritesProject(favorites));
112         dispatch<any>(initPublicFavoritesProject(publicFavorites));
113         dispatch<any>(initSearchProject(search));
114
115         if (preloadParams && preloadParams.selectedItemUuids.length) {
116             await dispatch<any>(loadInitialValue(
117                 preloadParams.selectedItemUuids,
118                 pickerId,
119                 preloadParams.includeDirectories,
120                 preloadParams.includeFiles,
121                 preloadParams.multi
122             ));
123         }
124     };
125
126 interface ReceiveTreePickerDataParams<T> {
127     data: T[];
128     extractNodeData: (value: T) => { id: string, value: T, status?: TreeNodeStatus };
129     id: string;
130     pickerId: string;
131 }
132
133 export const receiveTreePickerData = <T>(params: ReceiveTreePickerDataParams<T>) =>
134     (dispatch: Dispatch) => {
135         const { data, extractNodeData, id, pickerId, } = params;
136         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
137             id,
138             nodes: data.map(item => initTreeNode(extractNodeData(item))),
139             pickerId,
140         }));
141         dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
142     };
143
144 export const extractGroupContentsNodeData = (expandableCollections: boolean) => (item: GroupContentsResource & TreeItemWithWeight) => {
145     if (item.uuid === "more-items-available") {
146         return {
147             id: item.uuid,
148             value: item,
149             status: TreeNodeStatus.LOADED
150         }
151     } else if (item.weight === TreeItemWeight.LIGHT) {
152         return {
153             id: SEARCH_PROJECT_ID_PREFIX+item.uuid,
154             value: item,
155             status: item.kind === ResourceKind.PROJECT
156                   ? TreeNodeStatus.INITIAL
157                   : item.kind === ResourceKind.COLLECTION && expandableCollections
158                   ? TreeNodeStatus.INITIAL
159                   : TreeNodeStatus.LOADED
160         };
161     } else {
162         return { id: item.uuid,
163                  value: item,
164                  status: item.kind === ResourceKind.PROJECT
165                        ? TreeNodeStatus.INITIAL
166                        : item.kind === ResourceKind.COLLECTION && expandableCollections
167                        ? TreeNodeStatus.INITIAL
168                        : TreeNodeStatus.LOADED
169         };
170     }
171 };
172
173 interface LoadProjectParamsWithId extends LoadProjectParams {
174     id: string;
175     pickerId: string;
176     loadShared?: boolean;
177 }
178
179 /**
180  * loadProject is used to load or refresh a project node in a tree picker
181  *   Errors are caught and a toast is shown if the project fails to load
182  */
183 export const loadProject = (params: LoadProjectParamsWithId) =>
184     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
185         const {
186             id,
187             pickerId,
188             includeCollections = false,
189             includeDirectories = false,
190             includeFiles = false,
191             includeFilterGroups = false,
192             loadShared = false,
193             options,
194         } = params;
195
196         const searching = (id === SEARCH_PROJECT_ID);
197         const state = getState();
198         const collectionFilter = state.treePickerSearch.collectionFilterValues[pickerId];
199         const projectFilter = state.treePickerSearch.projectSearchValues[pickerId];
200
201         let filterB = new FilterBuilder();
202
203         let includeOwners: string|undefined = undefined;
204
205         if (id.startsWith(SEARCH_PROJECT_ID_PREFIX)) {
206             return;
207         }
208
209         if (searching) {
210             // opening top level search
211             if (projectFilter) {
212                 includeOwners = "owner_uuid";
213                 filterB = filterB.addIsA('uuid', [ResourceKind.PROJECT]);
214
215                 const objtype = extractUuidObjectType(projectFilter);
216                 if (objtype === ResourceObjectType.GROUP || objtype === ResourceObjectType.USER) {
217                         filterB = filterB.addEqual('uuid', projectFilter);
218                 }
219                 else {
220                     filterB = filterB.addFullTextSearch(projectFilter, 'groups');
221                 }
222
223             } else if (collectionFilter) {
224                 includeOwners = "owner_uuid";
225                 filterB = filterB.addIsA('uuid', [ResourceKind.COLLECTION]);
226
227                 const objtype = extractUuidObjectType(collectionFilter);
228                 if (objtype === ResourceObjectType.COLLECTION) {
229                     filterB = filterB.addEqual('uuid', collectionFilter);
230                 } else if (COLLECTION_PDH_REGEX.exec(collectionFilter)) {
231                     filterB = filterB.addEqual('portable_data_hash', collectionFilter);
232                 } else {
233                     filterB = filterB.addFullTextSearch(collectionFilter, 'collections');
234                 }
235             } else {
236                 return;
237             }
238         } else {
239             // opening a folder below the top level
240             if (includeCollections) {
241                 filterB = filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION]);
242             } else {
243                 filterB = filterB.addIsA('uuid', [ResourceKind.PROJECT]);
244             }
245
246             if (projectFilter) {
247                 filterB = filterB.addFullTextSearch(projectFilter, 'groups');
248             }
249             if (collectionFilter) {
250                 filterB = filterB.addFullTextSearch(collectionFilter, 'collections');
251             }
252         }
253
254         filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
255
256         const globalSearch = loadShared || id === SEARCH_PROJECT_ID;
257
258         const filters = filterB.getFilters();
259
260         // Must be under 1000
261         const itemLimit = 200;
262
263         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
264
265         try {
266             let { items, included } = await services.groupsService.contents(globalSearch ? '' : id,
267                                                                               { filters,
268                                                                                 excludeHomeProject: loadShared || undefined,
269                                                                                 limit: itemLimit+1,
270                                                                                 count: "none",
271                                                                                 include: includeOwners,
272             });
273
274             if (!included) {
275                 includeOwners = undefined;
276             }
277
278             //let rootItems: GroupContentsResource[] | GroupContentsIncludedResource[] = items;
279             let rootItems: any[] = items;
280
281             const seen = {};
282
283             if (includeOwners && included) {
284                 included = included.filter(item => {
285                     if (seen.hasOwnProperty(item.uuid)) {
286                         return false;
287                     } else {
288                         seen[item.uuid] = item;
289                         return true;
290                     }
291                 });
292                 dispatch<any>(updateResources(included));
293
294                 rootItems = included;
295             }
296
297             items = items.filter(item => {
298                 if (seen.hasOwnProperty(item.uuid)) {
299                     return false;
300                 } else {
301                     seen[item.uuid] = item;
302                     if (!seen[item.ownerUuid] && includeOwners) {
303                         rootItems.push(item);
304                     }
305                     return true;
306                 }
307             });
308             dispatch<any>(updateResources(items));
309
310             if (items.length > itemLimit) {
311                 rootItems.push({
312                     uuid: "more-items-available-"+id,
313                     kind: ResourceKind.WORKFLOW,
314                     name: `*** Not all items listed, reduce item count with search or filter ***`,
315                     description: "",
316                     definition: "",
317                     ownerUuid: "",
318                     createdAt: "",
319                     modifiedByUserUuid: "",
320                     modifiedAt: "",
321                     href: "",
322                     etag: ""
323                 });
324             }
325
326             dispatch<any>(receiveTreePickerData<GroupContentsResource>({
327                 id,
328                 pickerId,
329                 data: rootItems.filter(item => {
330                     if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
331                         return false;
332                     }
333
334                     if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
335                         return false;
336                     }
337                     return true;
338                 }).map(item => {
339                     if (extractUuidObjectType(item.uuid) === ResourceObjectType.USER) {
340                         return {...item,
341                                 uuid: item.uuid,
342                                 name: item['fullName'] + " Home Project",
343                                 weight: includeOwners ? TreeItemWeight.LIGHT : TreeItemWeight.NORMAL,
344                                 kind: ResourceKind.USER,
345                         }
346                     }
347                     return {...item,
348                             uuid: item.uuid,
349                             weight: includeOwners ? TreeItemWeight.LIGHT : TreeItemWeight.NORMAL,};
350
351                 }),
352                 extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
353             }));
354
355             if (includeOwners) {
356                 // Searching, we already have the
357                 // contents to put in the owner projects so load it up.
358                 const projects = {};
359                 items.forEach(item => {
360                     if (!projects.hasOwnProperty(item.ownerUuid)) {
361                         projects[item.ownerUuid] = [];
362                     }
363                     projects[item.ownerUuid].push({...item, weight: TreeItemWeight.DARK});
364                 });
365                 for (const prj in projects) {
366                     dispatch<any>(receiveTreePickerData<GroupContentsResource>({
367                         id: SEARCH_PROJECT_ID_PREFIX+prj,
368                         pickerId,
369                         data: projects[prj],
370                         extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
371                     }));
372                 }
373             }
374         } catch(e) {
375             console.error("Failed to load project into tree picker:", e);;
376             dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load project`, kind: SnackbarKind.ERROR }));
377         }
378     };
379
380 export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) =>
381     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
382         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
383
384         const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
385         if (picker) {
386
387             const node = getNode(id)(picker);
388             if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
389                 const files = (await services.collectionService.files(node.value.uuid))
390                     .filter((file) => (
391                         (includeFiles) ||
392                         (includeDirectories && file.type === CollectionFileType.DIRECTORY)
393                     ));
394                 const tree = createCollectionFilesTree(files);
395                 const sorted = sortFilesTree(tree);
396                 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
397
398                 // await tree modifications so that consumers can guarantee node presence
399                 await dispatch(
400                     treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
401                         id,
402                         pickerId,
403                         subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
404                     }));
405
406                 // Expand collection root node
407                 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
408             }
409         }
410     };
411
412 export const HOME_PROJECT_ID = 'Home Projects';
413 export const initUserProject = (pickerId: string) =>
414     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
415         const uuid = getUserUuid(getState());
416         if (uuid) {
417             dispatch(receiveTreePickerData({
418                 id: '',
419                 pickerId,
420                 data: [{ uuid, name: HOME_PROJECT_ID }],
421                 extractNodeData: value => ({
422                     id: value.uuid,
423                     status: TreeNodeStatus.INITIAL,
424                     value,
425                 }),
426             }));
427         }
428     };
429 export const loadUserProject = (pickerId: string, includeCollections = false, includeDirectories = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
430     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
431         const uuid = getUserUuid(getState());
432         if (uuid) {
433             dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
434         }
435     };
436
437 export const SHARED_PROJECT_ID = 'Shared with me';
438 export const initSharedProject = (pickerId: string) =>
439     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
440         dispatch(receiveTreePickerData({
441             id: '',
442             pickerId,
443             data: [{ uuid: SHARED_PROJECT_ID, name: SHARED_PROJECT_ID }],
444             extractNodeData: value => ({
445                 id: value.uuid,
446                 status: TreeNodeStatus.INITIAL,
447                 value,
448             }),
449         }));
450     };
451
452 type PickerItemPreloadData = {
453     itemId: string;
454     mainItemUuid: string;
455     ancestors: (GroupResource | CollectionResource)[];
456     isHomeProjectItem: boolean;
457 }
458
459 type PickerTreePreloadData = {
460     tree: Tree<GroupResource | CollectionResource>;
461     pickerTreeId: string;
462     pickerTreeRootUuid: string;
463 };
464
465 export const loadInitialValue = (pickerItemIds: string[], pickerId: string, includeDirectories: boolean, includeFiles: boolean, multi: boolean,) =>
466     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
467         const homeUuid = getUserUuid(getState());
468
469         // Request ancestor trees in paralell and save home project status
470         const pickerItemsData: PickerItemPreloadData[] = await Promise.allSettled(pickerItemIds.map(async itemId => {
471             const mainItemUuid = itemId.includes('/') ? itemId.split('/')[0] : itemId;
472
473             const ancestors = (await services.ancestorsService.ancestors(mainItemUuid, ''))
474             .filter(item =>
475                 item.kind === ResourceKind.GROUP ||
476                 item.kind === ResourceKind.COLLECTION
477             ) as (GroupResource | CollectionResource)[];
478
479             if (ancestors.length === 0) {
480                 return Promise.reject({item: itemId});
481             }
482
483             const isHomeProjectItem = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid));
484
485             return {
486                 itemId,
487                 mainItemUuid,
488                 ancestors,
489                 isHomeProjectItem,
490             };
491         })).then((res) => {
492             // Show toast if any selections failed to restore
493             const rejectedPromises = res.filter((promiseResult): promiseResult is PromiseRejectedResult => (promiseResult.status === 'rejected'));
494             if (rejectedPromises.length) {
495                 rejectedPromises.forEach(item => {
496                     console.error("The following item failed to load into the tree picker", item.reason);
497                 });
498                 dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Some selections failed to load and were removed. See console for details.`, kind: SnackbarKind.ERROR }));
499             }
500             // Filter out any failed promises and map to resulting preload data with ancestors
501             return res.filter((promiseResult): promiseResult is PromiseFulfilledResult<PickerItemPreloadData> => (
502                 promiseResult.status === 'fulfilled'
503             )).map(res => res.value)
504         });
505
506         // Group items to preload / ancestor data by home/shared picker and create initial Trees to preload
507         const initialTreePreloadData: PickerTreePreloadData[] = [
508             pickerItemsData.filter((item) => item.isHomeProjectItem),
509             pickerItemsData.filter((item) => !item.isHomeProjectItem),
510         ]
511             .filter((items) => items.length > 0)
512             .map((itemGroup) =>
513                 itemGroup.reduce(
514                     (preloadTree, itemData) => ({
515                         tree: createInitialPickerTree(
516                             itemData.ancestors,
517                             itemData.mainItemUuid,
518                             preloadTree.tree
519                         ),
520                         pickerTreeId: getPickerItemTreeId(itemData, homeUuid, pickerId),
521                         pickerTreeRootUuid: getPickerItemRootUuid(itemData, homeUuid),
522                     }),
523                     {
524                         tree: createTree<GroupResource | CollectionResource>(),
525                         pickerTreeId: '',
526                         pickerTreeRootUuid: '',
527                     } as PickerTreePreloadData
528                 )
529             );
530
531         // Load initial trees into corresponding picker store
532         await Promise.all(initialTreePreloadData.map(preloadTree => (
533             dispatch(
534                 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
535                     id: preloadTree.pickerTreeRootUuid,
536                     pickerId: preloadTree.pickerTreeId,
537                     subtree: preloadTree.tree,
538                 })
539             )
540         )));
541
542         // Await loading collection before attempting to select items
543         await Promise.all(pickerItemsData.map(async itemData => {
544             const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
545
546             // Selected item resides in collection subpath
547             if (itemData.itemId.includes('/')) {
548                 // Load collection into tree
549                 // loadCollection includes more than dispatched actions and must be awaited
550                 await dispatch(loadCollection(itemData.mainItemUuid, pickerTreeId, includeDirectories, includeFiles));
551             }
552             // Expand nodes down to destination
553             dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE_ANCESTORS({ id: itemData.itemId, pickerId: pickerTreeId }));
554         }));
555
556         // Select or activate nodes
557         pickerItemsData.forEach(itemData => {
558             const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
559
560             if (multi) {
561                 dispatch(treePickerActions.SELECT_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId, cascade: false}));
562             } else {
563                 dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId }));
564             }
565         });
566
567         // Refresh triggers loading in all adjacent items that were not included in the ancestor tree
568         await initialTreePreloadData.map(preloadTree => dispatch(treePickerSearchActions.REFRESH_TREE_PICKER({ pickerId: preloadTree.pickerTreeId })));
569     }
570
571 const getPickerItemTreeId = (itemData: PickerItemPreloadData, homeUuid: string | undefined, pickerId: string) => {
572     const { home, shared } = getProjectsTreePickerIds(pickerId);
573     return ((itemData.isHomeProjectItem && homeUuid) ? home : shared);
574 };
575
576 const getPickerItemRootUuid = (itemData: PickerItemPreloadData, homeUuid: string | undefined) => {
577     return (itemData.isHomeProjectItem && homeUuid) ? homeUuid : SHARED_PROJECT_ID;
578 };
579
580 export const FAVORITES_PROJECT_ID = 'Favorites';
581 export const initFavoritesProject = (pickerId: string) =>
582     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
583         dispatch(receiveTreePickerData({
584             id: '',
585             pickerId,
586             data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
587             extractNodeData: value => ({
588                 id: value.uuid,
589                 status: TreeNodeStatus.INITIAL,
590                 value,
591             }),
592         }));
593     };
594
595 export const PUBLIC_FAVORITES_PROJECT_ID = 'Public Favorites';
596 export const initPublicFavoritesProject = (pickerId: string) =>
597     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
598         dispatch(receiveTreePickerData({
599             id: '',
600             pickerId,
601             data: [{ uuid: PUBLIC_FAVORITES_PROJECT_ID, name: PUBLIC_FAVORITES_PROJECT_ID }],
602             extractNodeData: value => ({
603                 id: value.uuid,
604                 status: TreeNodeStatus.INITIAL,
605                 value,
606             }),
607         }));
608     };
609
610 export const SEARCH_PROJECT_ID = 'Search all Projects';
611 export const initSearchProject = (pickerId: string) =>
612     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
613         dispatch(receiveTreePickerData({
614             id: '',
615             pickerId,
616             data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
617             extractNodeData: value => ({
618                 id: value.uuid,
619                 status: TreeNodeStatus.INITIAL,
620                 value,
621             }),
622         }));
623     };
624
625
626 interface LoadFavoritesProjectParams {
627     pickerId: string;
628     includeCollections?: boolean;
629     includeDirectories?: boolean;
630     includeFiles?: boolean;
631     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
632 }
633
634 export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
635     options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) =>
636     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
637         const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
638         const uuid = getUserUuid(getState());
639         if (uuid) {
640             const filters = pipe(
641                 (fb: FilterBuilder) => includeCollections
642                     ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
643                     : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
644                 fb => fb.getFilters(),
645             )(new FilterBuilder());
646
647             const { items } = await services.favoriteService.list(uuid, { filters }, options.showOnlyOwned);
648
649             dispatch<any>(receiveTreePickerData<GroupContentsResource>({
650                 id: 'Favorites',
651                 pickerId,
652                 data: items.filter((item) => {
653                     if (options.showOnlyWritable && !(item as GroupResource).canWrite) {
654                         return false;
655                     }
656
657                     if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
658                         return false;
659                     }
660
661                     return true;
662                 }),
663                 extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
664             }));
665         }
666     };
667
668 export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =>
669     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
670         const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
671         const uuidPrefix = getState().auth.config.uuidPrefix;
672         const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
673
674         // TODO:
675         // favorites and public favorites ought to use a single method
676         // after getting back a list of links, need to look and stash the resources
677
678         const filters = pipe(
679             (fb: FilterBuilder) => includeCollections
680                 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
681                 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
682             fb => fb
683                 .addEqual('link_class', LinkClass.STAR)
684                 .addEqual('owner_uuid', publicProjectUuid)
685                 .getFilters(),
686         )(new FilterBuilder());
687
688         const { items } = await services.linkService.list({ filters });
689
690         dispatch<any>(receiveTreePickerData<LinkResource>({
691             id: 'Public Favorites',
692             pickerId,
693             data: items.filter(item => {
694                 if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
695                     return false;
696                 }
697
698                 return true;
699             }),
700             extractNodeData: item => ({
701                 id: item.headUuid,
702                 value: item,
703                 status: item.headKind === ResourceKind.PROJECT
704                     ? TreeNodeStatus.INITIAL
705                     : includeDirectories || includeFiles
706                         ? TreeNodeStatus.INITIAL
707                         : TreeNodeStatus.LOADED
708             }),
709         }));
710     };
711
712 export const receiveTreePickerProjectsData = (id: string, projects: ProjectResource[], pickerId: string) =>
713     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
714         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
715             id,
716             nodes: projects.map(project => initTreeNode({ id: project.uuid, value: project })),
717             pickerId,
718         }));
719
720         dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
721     };
722
723 export const loadProjectTreePickerProjects = (id: string) =>
724     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
725         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PROJECTS }));
726
727
728         const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
729         const { items } = await services.projectService.list(buildParams(ownerUuid));
730
731         dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
732     };
733
734 export const loadFavoriteTreePickerProjects = (id: string) =>
735     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
736         const parentId = getUserUuid(getState()) || '';
737
738         if (id === '') {
739             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.FAVORITES }));
740             const { items } = await services.favoriteService.list(parentId);
741             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
742         } else {
743             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.FAVORITES }));
744             const { items } = await services.projectService.list(buildParams(id));
745             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.FAVORITES));
746         }
747
748     };
749
750 export const loadPublicFavoriteTreePickerProjects = (id: string) =>
751     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
752         const parentId = getUserUuid(getState()) || '';
753
754         if (id === '') {
755             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.PUBLIC_FAVORITES }));
756             const { items } = await services.favoriteService.list(parentId);
757             dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.PUBLIC_FAVORITES));
758         } else {
759             dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PUBLIC_FAVORITES }));
760             const { items } = await services.projectService.list(buildParams(id));
761             dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PUBLIC_FAVORITES));
762         }
763
764     };
765
766 const buildParams = (ownerUuid: string) => {
767     return {
768         filters: new FilterBuilder()
769             .addEqual('owner_uuid', ownerUuid)
770             .getFilters(),
771         order: new OrderBuilder<ProjectResource>()
772             .addAsc('name')
773             .getOrder()
774     };
775 };
776
777 /**
778  * Given a tree picker item, return collection uuid and path
779  *   if the item represents a valid target/destination location
780  */
781 export type FileOperationLocation = {
782     name: string;
783     uuid: string;
784     pdh?: string;
785     subpath: string;
786 }
787 export const getFileOperationLocation = (item: ProjectsTreePickerItem) =>
788     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<FileOperationLocation | undefined> => {
789         if ('kind' in item && item.kind === ResourceKind.COLLECTION) {
790             return {
791                 name: item.name,
792                 uuid: item.uuid,
793                 pdh: item.portableDataHash,
794                 subpath: '/',
795             };
796         } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
797             const uuid = getCollectionResourceCollectionUuid(item.id);
798             if (uuid) {
799                 const collection = getResource<CollectionResource>(uuid)(getState().resources);
800                 if (collection) {
801                     const itemPath = [item.path, item.name].join('/');
802
803                     return {
804                         name: item.name,
805                         uuid,
806                         pdh: collection.portableDataHash,
807                         subpath: itemPath,
808                     };
809                 }
810             }
811         }
812         return undefined;
813     };
814
815 /**
816  * Create an expanded tree picker subtree from array of nested projects/collection
817  *   First item is assumed to be root and gets empty parent id
818  *   Nodes must be sorted from top down to prevent orphaned nodes
819  */
820 export const createInitialPickerTree = (sortedAncestors: Array<GroupResource | CollectionResource>, tailUuid: string, initialTree: Tree<GroupResource | CollectionResource>) => {
821     return sortedAncestors
822         .reduce((tree, item, index) => {
823             if (getNode(item.uuid)(tree)) {
824                 return tree;
825             } else {
826                 return setNode({
827                     children: [],
828                     id: item.uuid,
829                     parent: index === 0 ? '' : item.ownerUuid,
830                     value: item,
831                     active: false,
832                     selected: false,
833                     expanded: false,
834                     status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
835                 })(tree);
836             }
837         }, initialTree);
838 };
839
840 export const fileOperationLocationToPickerId = (location: FileOperationLocation): string => {
841     let id = location.uuid;
842     if (location.subpath.length && location.subpath !== '/') {
843         id = id + location.subpath;
844     }
845     return id;
846 }