1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
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";
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 }>()
47 export type TreePickerAction = UnionOf<typeof treePickerActions>;
49 export interface LoadProjectParams {
50 includeCollections?: boolean;
51 includeDirectories?: boolean;
52 includeFiles?: boolean;
53 includeFilterGroups?: boolean;
54 options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; };
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 }>(),
64 export type TreePickerSearchAction = UnionOf<typeof treePickerSearchActions>;
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`,
74 export const SEARCH_PROJECT_ID_PREFIX = "search-";
76 export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
78 () => values(getProjectsTreePickerIds(pickerId)),
81 .map(id => getTreePicker<Value>(id)(state)),
84 .map(getNodeDescendants(''))
85 .reduce((allNodes, nodes) => allNodes.concat(nodes), []),
88 .reduce((map, node) =>
90 ? map.set(node.id, node)
91 : map, new Map<string, TreeNode<Value>>())
94 uniqueNodes => Array.from(uniqueNodes),
96 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
97 getAllNodes<Value>(pickerId, node => node.selected)(state);
99 interface TreePickerPreloadParams {
100 selectedItemUuids: string[];
101 includeDirectories: boolean;
102 includeFiles: boolean;
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));
115 if (preloadParams && preloadParams.selectedItemUuids.length) {
116 await dispatch<any>(loadInitialValue(
117 preloadParams.selectedItemUuids,
119 preloadParams.includeDirectories,
120 preloadParams.includeFiles,
126 interface ReceiveTreePickerDataParams<T> {
128 extractNodeData: (value: T) => { id: string, value: T, status?: TreeNodeStatus };
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({
138 nodes: data.map(item => initTreeNode(extractNodeData(item))),
141 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
144 export const extractGroupContentsNodeData = (expandableCollections: boolean) => (item: GroupContentsResource & TreeItemWithWeight) => {
145 if (item.uuid === "more-items-available") {
149 status: TreeNodeStatus.LOADED
151 } else if (item.weight === TreeItemWeight.LIGHT) {
153 id: SEARCH_PROJECT_ID_PREFIX+item.uuid,
155 status: item.kind === ResourceKind.PROJECT
156 ? TreeNodeStatus.INITIAL
157 : item.kind === ResourceKind.COLLECTION && expandableCollections
158 ? TreeNodeStatus.INITIAL
159 : TreeNodeStatus.LOADED
162 return { id: item.uuid,
164 status: item.kind === ResourceKind.PROJECT
165 ? TreeNodeStatus.INITIAL
166 : item.kind === ResourceKind.COLLECTION && expandableCollections
167 ? TreeNodeStatus.INITIAL
168 : TreeNodeStatus.LOADED
173 interface LoadProjectParamsWithId extends LoadProjectParams {
176 loadShared?: boolean;
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
183 export const loadProject = (params: LoadProjectParamsWithId) =>
184 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
188 includeCollections = false,
189 includeDirectories = false,
190 includeFiles = false,
191 includeFilterGroups = false,
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];
201 let filterB = new FilterBuilder();
203 let includeOwners: string|undefined = undefined;
205 if (id.startsWith(SEARCH_PROJECT_ID_PREFIX)) {
210 // opening top level search
212 includeOwners = "owner_uuid";
213 filterB = filterB.addIsA('uuid', [ResourceKind.PROJECT]);
215 const objtype = extractUuidObjectType(projectFilter);
216 if (objtype === ResourceObjectType.GROUP || objtype === ResourceObjectType.USER) {
217 filterB = filterB.addEqual('uuid', projectFilter);
220 filterB = filterB.addFullTextSearch(projectFilter, 'groups');
223 } else if (collectionFilter) {
224 includeOwners = "owner_uuid";
225 filterB = filterB.addIsA('uuid', [ResourceKind.COLLECTION]);
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);
233 filterB = filterB.addFullTextSearch(collectionFilter, 'collections');
239 // opening a folder below the top level
240 if (includeCollections) {
241 filterB = filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION]);
243 filterB = filterB.addIsA('uuid', [ResourceKind.PROJECT]);
247 filterB = filterB.addFullTextSearch(projectFilter, 'groups');
249 if (collectionFilter) {
250 filterB = filterB.addFullTextSearch(collectionFilter, 'collections');
254 filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
256 const globalSearch = loadShared || id === SEARCH_PROJECT_ID;
258 const filters = filterB.getFilters();
260 // Must be under 1000
261 const itemLimit = 200;
263 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
266 let { items, included } = await services.groupsService.contents(globalSearch ? '' : id,
268 excludeHomeProject: loadShared || undefined,
271 include: includeOwners,
275 includeOwners = undefined;
278 //let rootItems: GroupContentsResource[] | GroupContentsIncludedResource[] = items;
279 let rootItems: any[] = items;
283 if (includeOwners && included) {
284 included = included.filter(item => {
285 if (seen.hasOwnProperty(item.uuid)) {
288 seen[item.uuid] = item;
292 dispatch<any>(updateResources(included));
294 rootItems = included;
297 items = items.filter(item => {
298 if (seen.hasOwnProperty(item.uuid)) {
301 seen[item.uuid] = item;
302 if (!seen[item.ownerUuid] && includeOwners) {
303 rootItems.push(item);
308 dispatch<any>(updateResources(items));
310 if (items.length > itemLimit) {
312 uuid: "more-items-available-"+id,
313 kind: ResourceKind.WORKFLOW,
314 name: `*** Not all items listed, reduce item count with search or filter ***`,
319 modifiedByUserUuid: "",
326 dispatch<any>(receiveTreePickerData<GroupContentsResource>({
329 data: rootItems.filter(item => {
330 if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
334 if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
339 if (extractUuidObjectType(item.uuid) === ResourceObjectType.USER) {
342 name: item['fullName'] + " Home Project",
343 weight: includeOwners ? TreeItemWeight.LIGHT : TreeItemWeight.NORMAL,
344 kind: ResourceKind.USER,
349 weight: includeOwners ? TreeItemWeight.LIGHT : TreeItemWeight.NORMAL,};
352 extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
356 // Searching, we already have the
357 // contents to put in the owner projects so load it up.
359 items.forEach(item => {
360 if (!projects.hasOwnProperty(item.ownerUuid)) {
361 projects[item.ownerUuid] = [];
363 projects[item.ownerUuid].push({...item, weight: TreeItemWeight.DARK});
365 for (const prj in projects) {
366 dispatch<any>(receiveTreePickerData<GroupContentsResource>({
367 id: SEARCH_PROJECT_ID_PREFIX+prj,
370 extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
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 }));
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 }));
384 const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
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))
392 (includeDirectories && file.type === CollectionFileType.DIRECTORY)
394 const tree = createCollectionFilesTree(files);
395 const sorted = sortFilesTree(tree);
396 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
398 // await tree modifications so that consumers can guarantee node presence
400 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
403 subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
406 // Expand collection root node
407 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
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());
417 dispatch(receiveTreePickerData({
420 data: [{ uuid, name: HOME_PROJECT_ID }],
421 extractNodeData: value => ({
423 status: TreeNodeStatus.INITIAL,
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());
433 dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
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({
443 data: [{ uuid: SHARED_PROJECT_ID, name: SHARED_PROJECT_ID }],
444 extractNodeData: value => ({
446 status: TreeNodeStatus.INITIAL,
452 type PickerItemPreloadData = {
454 mainItemUuid: string;
455 ancestors: (GroupResource | CollectionResource)[];
456 isHomeProjectItem: boolean;
459 type PickerTreePreloadData = {
460 tree: Tree<GroupResource | CollectionResource>;
461 pickerTreeId: string;
462 pickerTreeRootUuid: string;
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());
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;
473 const ancestors = (await services.ancestorsService.ancestors(mainItemUuid, ''))
475 item.kind === ResourceKind.GROUP ||
476 item.kind === ResourceKind.COLLECTION
477 ) as (GroupResource | CollectionResource)[];
479 if (ancestors.length === 0) {
480 return Promise.reject({item: itemId});
483 const isHomeProjectItem = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid));
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);
498 dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Some selections failed to load and were removed. See console for details.`, kind: SnackbarKind.ERROR }));
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)
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),
511 .filter((items) => items.length > 0)
514 (preloadTree, itemData) => ({
515 tree: createInitialPickerTree(
517 itemData.mainItemUuid,
520 pickerTreeId: getPickerItemTreeId(itemData, homeUuid, pickerId),
521 pickerTreeRootUuid: getPickerItemRootUuid(itemData, homeUuid),
524 tree: createTree<GroupResource | CollectionResource>(),
526 pickerTreeRootUuid: '',
527 } as PickerTreePreloadData
531 // Load initial trees into corresponding picker store
532 await Promise.all(initialTreePreloadData.map(preloadTree => (
534 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
535 id: preloadTree.pickerTreeRootUuid,
536 pickerId: preloadTree.pickerTreeId,
537 subtree: preloadTree.tree,
542 // Await loading collection before attempting to select items
543 await Promise.all(pickerItemsData.map(async itemData => {
544 const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
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));
552 // Expand nodes down to destination
553 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE_ANCESTORS({ id: itemData.itemId, pickerId: pickerTreeId }));
556 // Select or activate nodes
557 pickerItemsData.forEach(itemData => {
558 const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
561 dispatch(treePickerActions.SELECT_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId, cascade: false}));
563 dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId }));
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 })));
571 const getPickerItemTreeId = (itemData: PickerItemPreloadData, homeUuid: string | undefined, pickerId: string) => {
572 const { home, shared } = getProjectsTreePickerIds(pickerId);
573 return ((itemData.isHomeProjectItem && homeUuid) ? home : shared);
576 const getPickerItemRootUuid = (itemData: PickerItemPreloadData, homeUuid: string | undefined) => {
577 return (itemData.isHomeProjectItem && homeUuid) ? homeUuid : SHARED_PROJECT_ID;
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({
586 data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
587 extractNodeData: value => ({
589 status: TreeNodeStatus.INITIAL,
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({
601 data: [{ uuid: PUBLIC_FAVORITES_PROJECT_ID, name: PUBLIC_FAVORITES_PROJECT_ID }],
602 extractNodeData: value => ({
604 status: TreeNodeStatus.INITIAL,
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({
616 data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
617 extractNodeData: value => ({
619 status: TreeNodeStatus.INITIAL,
626 interface LoadFavoritesProjectParams {
628 includeCollections?: boolean;
629 includeDirectories?: boolean;
630 includeFiles?: boolean;
631 options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
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());
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());
647 const { items } = await services.favoriteService.list(uuid, { filters }, options.showOnlyOwned);
649 dispatch<any>(receiveTreePickerData<GroupContentsResource>({
652 data: items.filter((item) => {
653 if (options.showOnlyWritable && !(item as GroupResource).canWrite) {
657 if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
663 extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
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`;
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
678 const filters = pipe(
679 (fb: FilterBuilder) => includeCollections
680 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
681 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
683 .addEqual('link_class', LinkClass.STAR)
684 .addEqual('owner_uuid', publicProjectUuid)
686 )(new FilterBuilder());
688 const { items } = await services.linkService.list({ filters });
690 dispatch<any>(receiveTreePickerData<LinkResource>({
691 id: 'Public Favorites',
693 data: items.filter(item => {
694 if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
700 extractNodeData: item => ({
703 status: item.headKind === ResourceKind.PROJECT
704 ? TreeNodeStatus.INITIAL
705 : includeDirectories || includeFiles
706 ? TreeNodeStatus.INITIAL
707 : TreeNodeStatus.LOADED
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({
716 nodes: projects.map(project => initTreeNode({ id: project.uuid, value: project })),
720 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
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 }));
728 const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
729 const { items } = await services.projectService.list(buildParams(ownerUuid));
731 dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
734 export const loadFavoriteTreePickerProjects = (id: string) =>
735 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
736 const parentId = getUserUuid(getState()) || '';
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));
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));
750 export const loadPublicFavoriteTreePickerProjects = (id: string) =>
751 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
752 const parentId = getUserUuid(getState()) || '';
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));
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));
766 const buildParams = (ownerUuid: string) => {
768 filters: new FilterBuilder()
769 .addEqual('owner_uuid', ownerUuid)
771 order: new OrderBuilder<ProjectResource>()
778 * Given a tree picker item, return collection uuid and path
779 * if the item represents a valid target/destination location
781 export type FileOperationLocation = {
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) {
793 pdh: item.portableDataHash,
796 } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
797 const uuid = getCollectionResourceCollectionUuid(item.id);
799 const collection = getResource<CollectionResource>(uuid)(getState().resources);
801 const itemPath = [item.path, item.name].join('/');
806 pdh: collection.portableDataHash,
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
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)) {
829 parent: index === 0 ? '' : item.ownerUuid,
834 status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
840 export const fileOperationLocationToPickerId = (location: FileOperationLocation): string => {
841 let id = location.uuid;
842 if (location.subpath.length && location.subpath !== '/') {
843 id = id + location.subpath;