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 { ResourceKind, ResourceObjectType, extractUuidObjectType, COLLECTION_PDH_REGEX } from 'models/resource';
15 import { GroupContentsResource } 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 { 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 import { call, put, takeEvery, takeLatest, getContext, cancelled, select } from "redux-saga/effects";
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 treePickerSearchSagas = unionize({
67 SET_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(),
68 LOAD_PROJECT: ofType<LoadProjectParamsWithId>(),
69 LOAD_SEARCH: ofType<LoadProjectParamsWithId>(),
70 // REFRESH_TREE_PICKER: ofType<{ pickerId: string }>(),
73 export function* setTreePickerProjectSearchWatcher() {
74 // Race conditions are handled in loadSearchWatcher so takeEvery is used here to avoid confusion
75 yield takeEvery(treePickerSearchSagas.tags.SET_PROJECT_SEARCH, setTreePickerProjectSearchSaga);
78 function* setTreePickerProjectSearchSaga({type, payload}: {
79 type: typeof treePickerSearchSagas.tags.SET_PROJECT_SEARCH,
80 payload: typeof treePickerSearchSagas._Record.SET_PROJECT_SEARCH,
83 const { pickerId , projectSearchValue } = payload;
84 const state: RootState = yield select();
85 const searchChanged = state.treePickerSearch.projectSearchValues[pickerId] !== projectSearchValue;
88 yield put(treePickerSearchActions.SET_TREE_PICKER_PROJECT_SEARCH(payload));
89 const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(state.treePicker);
91 const loadParams = state.treePickerSearch.loadProjectParams[pickerId];
92 // Put is non-blocking so race-condition prevention is handled by the loadSearchWatcher
93 yield put(treePickerSearchSagas.LOAD_SEARCH({
95 id: SEARCH_PROJECT_ID,
101 yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to search`, kind: SnackbarKind.ERROR }));
105 export const getProjectsTreePickerIds = (pickerId: string) => ({
106 home: `${pickerId}_home`,
107 shared: `${pickerId}_shared`,
108 favorites: `${pickerId}_favorites`,
109 publicFavorites: `${pickerId}_publicFavorites`,
110 search: `${pickerId}_search`,
113 export const SEARCH_PROJECT_ID_PREFIX = "search-";
115 export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
117 () => values(getProjectsTreePickerIds(pickerId)),
120 .map(id => getTreePicker<Value>(id)(state)),
123 .map(getNodeDescendants(''))
124 .reduce((allNodes, nodes) => allNodes.concat(nodes), []),
127 .reduce((map, node) =>
129 ? map.set(node.id, node)
130 : map, new Map<string, TreeNode<Value>>())
133 uniqueNodes => Array.from(uniqueNodes),
135 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
136 getAllNodes<Value>(pickerId, node => node.selected)(state);
138 interface TreePickerPreloadParams {
139 selectedItemUuids: string[];
140 includeDirectories: boolean;
141 includeFiles: boolean;
145 export const initProjectsTreePicker = (pickerId: string, preloadParams?: TreePickerPreloadParams) =>
146 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
147 const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
148 dispatch<any>(initUserProject(home));
149 dispatch<any>(initSharedProject(shared));
150 dispatch<any>(initFavoritesProject(favorites));
151 dispatch<any>(initPublicFavoritesProject(publicFavorites));
152 dispatch<any>(initSearchProject(search));
154 if (preloadParams && preloadParams.selectedItemUuids.length) {
155 await dispatch<any>(loadInitialValue(
156 preloadParams.selectedItemUuids,
158 preloadParams.includeDirectories,
159 preloadParams.includeFiles,
165 interface ReceiveTreePickerDataParams<T> {
167 extractNodeData: (value: T) => { id: string, value: T, status?: TreeNodeStatus };
172 export const receiveTreePickerData = <T>(params: ReceiveTreePickerDataParams<T>) =>
173 (dispatch: Dispatch) => {
174 const { data, extractNodeData, id, pickerId, } = params;
175 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
177 nodes: data.map(item => initTreeNode(extractNodeData(item))),
180 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
183 export const extractGroupContentsNodeData = (expandableCollections: boolean) => (item: GroupContentsResource & TreeItemWithWeight) => {
184 if (item.uuid === "more-items-available") {
188 status: TreeNodeStatus.LOADED
190 } else if (item.weight === TreeItemWeight.LIGHT) {
192 id: SEARCH_PROJECT_ID_PREFIX+item.uuid,
194 status: item.kind === ResourceKind.PROJECT
195 ? TreeNodeStatus.INITIAL
196 : item.kind === ResourceKind.COLLECTION && expandableCollections
197 ? TreeNodeStatus.INITIAL
198 : TreeNodeStatus.LOADED
201 return { id: item.uuid,
203 status: item.kind === ResourceKind.PROJECT
204 ? TreeNodeStatus.INITIAL
205 : item.kind === ResourceKind.COLLECTION && expandableCollections
206 ? TreeNodeStatus.INITIAL
207 : TreeNodeStatus.LOADED
212 interface LoadProjectParamsWithId extends LoadProjectParams {
215 loadShared?: boolean;
219 * Kicks off a picker search load that allows paralell runs
220 * Used for expanding nodes
222 export const loadProject = (params: LoadProjectParamsWithId) => (treePickerSearchSagas.LOAD_PROJECT(params));
223 export function* loadProjectWatcher() {
224 yield takeEvery(treePickerSearchSagas.tags.LOAD_PROJECT, loadProjectSaga);
228 * Asynchronously kicks off a race-free picker search load - does not block when used this way
230 export const loadSearch = (params: LoadProjectParamsWithId) => (treePickerSearchSagas.LOAD_SEARCH(params));
231 export function* loadSearchWatcher() {
232 yield takeLatest(treePickerSearchSagas.tags.LOAD_SEARCH, loadProjectSaga);
236 * loadProjectSaga is used to load or refresh a project node in a tree picker
237 * Errors are caught and a toast is shown if the project fails to load
238 * Blocks when called directly with call(), can be composed into race-free groups
240 function* loadProjectSaga({type, payload}: {
241 type: typeof treePickerSearchSagas.tags.LOAD_PROJECT,
242 payload: typeof treePickerSearchSagas._Record.LOAD_PROJECT,
245 const services: ServiceRepository = yield getContext("services");
246 const state: RootState = yield select();
251 includeCollections = false,
252 includeDirectories = false,
253 includeFiles = false,
254 includeFilterGroups = false,
259 const searching = (id === SEARCH_PROJECT_ID);
260 const collectionFilter = state.treePickerSearch.collectionFilterValues[pickerId];
261 const projectFilter = state.treePickerSearch.projectSearchValues[pickerId];
263 let filterB = new FilterBuilder();
265 let includeOwners: string|undefined = undefined;
267 if (id.startsWith(SEARCH_PROJECT_ID_PREFIX)) {
272 // opening top level search
274 includeOwners = "owner_uuid";
275 filterB = filterB.addIsA('uuid', [ResourceKind.PROJECT]);
277 const objtype = extractUuidObjectType(projectFilter);
278 if (objtype === ResourceObjectType.GROUP || objtype === ResourceObjectType.USER) {
279 filterB = filterB.addEqual('uuid', projectFilter);
282 filterB = filterB.addFullTextSearch(projectFilter, 'groups');
285 } else if (collectionFilter) {
286 includeOwners = "owner_uuid";
287 filterB = filterB.addIsA('uuid', [ResourceKind.COLLECTION]);
289 const objtype = extractUuidObjectType(collectionFilter);
290 if (objtype === ResourceObjectType.COLLECTION) {
291 filterB = filterB.addEqual('uuid', collectionFilter);
292 } else if (COLLECTION_PDH_REGEX.exec(collectionFilter)) {
293 filterB = filterB.addEqual('portable_data_hash', collectionFilter);
295 filterB = filterB.addFullTextSearch(collectionFilter, 'collections');
301 // opening a folder below the top level
302 if (includeCollections) {
303 filterB = filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION]);
305 filterB = filterB.addIsA('uuid', [ResourceKind.PROJECT]);
309 filterB = filterB.addFullTextSearch(projectFilter, 'groups');
311 if (collectionFilter) {
312 filterB = filterB.addFullTextSearch(collectionFilter, 'collections');
316 filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
318 const globalSearch = loadShared || id === SEARCH_PROJECT_ID;
320 const filters = filterB.getFilters();
322 // Must be under 1000
323 const itemLimit = 200;
325 yield put(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
327 let { items, included } = yield call({context: services.groupsService, fn: services.groupsService.contents},
328 globalSearch ? '' : id,
330 excludeHomeProject: loadShared || undefined,
333 include: includeOwners,
337 includeOwners = undefined;
340 //let rootItems: GroupContentsResource[] | GroupContentsIncludedResource[] = items;
341 let rootItems: any[] = items;
345 if (includeOwners && included) {
346 included = included.filter(item => {
347 if (seen.hasOwnProperty(item.uuid)) {
350 seen[item.uuid] = item;
354 yield put(updateResources(included));
356 rootItems = included;
359 items = items.filter(item => {
360 if (seen.hasOwnProperty(item.uuid)) {
363 seen[item.uuid] = item;
364 if (!seen[item.ownerUuid] && includeOwners) {
365 rootItems.push(item);
370 yield put(updateResources(items));
372 if (items.length > itemLimit) {
374 uuid: "more-items-available-"+id,
375 kind: ResourceKind.WORKFLOW,
376 name: `*** Not all items listed, reduce item count with search or filter ***`,
381 modifiedByUserUuid: "",
388 yield put(receiveTreePickerData<GroupContentsResource>({
391 data: rootItems.filter(item => {
392 if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
396 if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
401 if (extractUuidObjectType(item.uuid) === ResourceObjectType.USER) {
404 name: item['fullName'] + " Home Project",
405 weight: includeOwners ? TreeItemWeight.LIGHT : TreeItemWeight.NORMAL,
406 kind: ResourceKind.USER,
411 weight: includeOwners ? TreeItemWeight.LIGHT : TreeItemWeight.NORMAL,};
414 extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
418 // Searching, we already have the
419 // contents to put in the owner projects so load it up.
421 items.forEach(item => {
422 if (!projects.hasOwnProperty(item.ownerUuid)) {
423 projects[item.ownerUuid] = [];
425 projects[item.ownerUuid].push({...item, weight: TreeItemWeight.DARK});
427 for (const prj in projects) {
428 yield put(receiveTreePickerData<GroupContentsResource>({
429 id: SEARCH_PROJECT_ID_PREFIX+prj,
432 extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
437 console.error("Failed to load project into tree picker:", e);;
438 yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load project`, kind: SnackbarKind.ERROR }));
440 // Optionally handle cleanup when task cancelled
441 // if (yield cancelled()) {}
445 export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) =>
446 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
447 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
449 const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
452 const node = getNode(id)(picker);
453 if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
454 const files = (await services.collectionService.files(node.value.uuid))
457 (includeDirectories && file.type === CollectionFileType.DIRECTORY)
459 const tree = createCollectionFilesTree(files);
460 const sorted = sortFilesTree(tree);
461 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
463 // await tree modifications so that consumers can guarantee node presence
465 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
468 subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
471 // Expand collection root node
472 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
477 export const HOME_PROJECT_ID = 'Home Projects';
478 export const initUserProject = (pickerId: string) =>
479 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
480 const uuid = getUserUuid(getState());
482 dispatch(receiveTreePickerData({
485 data: [{ uuid, name: HOME_PROJECT_ID }],
486 extractNodeData: value => ({
488 status: TreeNodeStatus.INITIAL,
494 export const loadUserProject = (pickerId: string, includeCollections = false, includeDirectories = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
495 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
496 const uuid = getUserUuid(getState());
498 dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
502 export const SHARED_PROJECT_ID = 'Shared with me';
503 export const initSharedProject = (pickerId: string) =>
504 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
505 dispatch(receiveTreePickerData({
508 data: [{ uuid: SHARED_PROJECT_ID, name: SHARED_PROJECT_ID }],
509 extractNodeData: value => ({
511 status: TreeNodeStatus.INITIAL,
517 type PickerItemPreloadData = {
519 mainItemUuid: string;
520 ancestors: (GroupResource | CollectionResource)[];
521 isHomeProjectItem: boolean;
524 type PickerTreePreloadData = {
525 tree: Tree<GroupResource | CollectionResource>;
526 pickerTreeId: string;
527 pickerTreeRootUuid: string;
530 export const loadInitialValue = (pickerItemIds: string[], pickerId: string, includeDirectories: boolean, includeFiles: boolean, multi: boolean,) =>
531 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
532 const homeUuid = getUserUuid(getState());
534 // Request ancestor trees in paralell and save home project status
535 const pickerItemsData: PickerItemPreloadData[] = await Promise.allSettled(pickerItemIds.map(async itemId => {
536 const mainItemUuid = itemId.includes('/') ? itemId.split('/')[0] : itemId;
538 const ancestors = (await services.ancestorsService.ancestors(mainItemUuid, ''))
540 item.kind === ResourceKind.GROUP ||
541 item.kind === ResourceKind.COLLECTION
542 ) as (GroupResource | CollectionResource)[];
544 if (ancestors.length === 0) {
545 return Promise.reject({item: itemId});
548 const isHomeProjectItem = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid));
557 // Show toast if any selections failed to restore
558 const rejectedPromises = res.filter((promiseResult): promiseResult is PromiseRejectedResult => (promiseResult.status === 'rejected'));
559 if (rejectedPromises.length) {
560 rejectedPromises.forEach(item => {
561 console.error("The following item failed to load into the tree picker", item.reason);
563 dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Some selections failed to load and were removed. See console for details.`, kind: SnackbarKind.ERROR }));
565 // Filter out any failed promises and map to resulting preload data with ancestors
566 return res.filter((promiseResult): promiseResult is PromiseFulfilledResult<PickerItemPreloadData> => (
567 promiseResult.status === 'fulfilled'
568 )).map(res => res.value)
571 // Group items to preload / ancestor data by home/shared picker and create initial Trees to preload
572 const initialTreePreloadData: PickerTreePreloadData[] = [
573 pickerItemsData.filter((item) => item.isHomeProjectItem),
574 pickerItemsData.filter((item) => !item.isHomeProjectItem),
576 .filter((items) => items.length > 0)
579 (preloadTree, itemData) => ({
580 tree: createInitialPickerTree(
582 itemData.mainItemUuid,
585 pickerTreeId: getPickerItemTreeId(itemData, homeUuid, pickerId),
586 pickerTreeRootUuid: getPickerItemRootUuid(itemData, homeUuid),
589 tree: createTree<GroupResource | CollectionResource>(),
591 pickerTreeRootUuid: '',
592 } as PickerTreePreloadData
596 // Load initial trees into corresponding picker store
597 await Promise.all(initialTreePreloadData.map(preloadTree => (
599 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
600 id: preloadTree.pickerTreeRootUuid,
601 pickerId: preloadTree.pickerTreeId,
602 subtree: preloadTree.tree,
607 // Await loading collection before attempting to select items
608 await Promise.all(pickerItemsData.map(async itemData => {
609 const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
611 // Selected item resides in collection subpath
612 if (itemData.itemId.includes('/')) {
613 // Load collection into tree
614 // loadCollection includes more than dispatched actions and must be awaited
615 await dispatch(loadCollection(itemData.mainItemUuid, pickerTreeId, includeDirectories, includeFiles));
617 // Expand nodes down to destination
618 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE_ANCESTORS({ id: itemData.itemId, pickerId: pickerTreeId }));
621 // Select or activate nodes
622 pickerItemsData.forEach(itemData => {
623 const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
626 dispatch(treePickerActions.SELECT_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId, cascade: false}));
628 dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId }));
632 // Refresh triggers loading in all adjacent items that were not included in the ancestor tree
633 await initialTreePreloadData.map(preloadTree => dispatch(treePickerSearchActions.REFRESH_TREE_PICKER({ pickerId: preloadTree.pickerTreeId })));
636 const getPickerItemTreeId = (itemData: PickerItemPreloadData, homeUuid: string | undefined, pickerId: string) => {
637 const { home, shared } = getProjectsTreePickerIds(pickerId);
638 return ((itemData.isHomeProjectItem && homeUuid) ? home : shared);
641 const getPickerItemRootUuid = (itemData: PickerItemPreloadData, homeUuid: string | undefined) => {
642 return (itemData.isHomeProjectItem && homeUuid) ? homeUuid : SHARED_PROJECT_ID;
645 export const FAVORITES_PROJECT_ID = 'Favorites';
646 export const initFavoritesProject = (pickerId: string) =>
647 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
648 dispatch(receiveTreePickerData({
651 data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
652 extractNodeData: value => ({
654 status: TreeNodeStatus.INITIAL,
660 export const PUBLIC_FAVORITES_PROJECT_ID = 'Public Favorites';
661 export const initPublicFavoritesProject = (pickerId: string) =>
662 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
663 dispatch(receiveTreePickerData({
666 data: [{ uuid: PUBLIC_FAVORITES_PROJECT_ID, name: PUBLIC_FAVORITES_PROJECT_ID }],
667 extractNodeData: value => ({
669 status: TreeNodeStatus.INITIAL,
675 export const SEARCH_PROJECT_ID = 'Search all Projects';
676 export const initSearchProject = (pickerId: string) =>
677 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
678 dispatch(receiveTreePickerData({
681 data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
682 extractNodeData: value => ({
684 status: TreeNodeStatus.INITIAL,
691 interface LoadFavoritesProjectParams {
693 includeCollections?: boolean;
694 includeDirectories?: boolean;
695 includeFiles?: boolean;
696 options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
699 export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
700 options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) =>
701 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
702 const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
703 const uuid = getUserUuid(getState());
705 const filters = pipe(
706 (fb: FilterBuilder) => includeCollections
707 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
708 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
709 fb => fb.getFilters(),
710 )(new FilterBuilder());
712 const { items } = await services.favoriteService.list(uuid, { filters }, options.showOnlyOwned);
714 dispatch<any>(receiveTreePickerData<GroupContentsResource>({
717 data: items.filter((item) => {
718 if (options.showOnlyWritable && !(item as GroupResource).canWrite) {
722 if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
728 extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
733 export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =>
734 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
735 const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
736 const uuidPrefix = getState().auth.config.uuidPrefix;
737 const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
740 // favorites and public favorites ought to use a single method
741 // after getting back a list of links, need to look and stash the resources
743 const filters = pipe(
744 (fb: FilterBuilder) => includeCollections
745 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
746 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
748 .addEqual('link_class', LinkClass.STAR)
749 .addEqual('owner_uuid', publicProjectUuid)
751 )(new FilterBuilder());
753 const { items } = await services.linkService.list({ filters });
755 dispatch<any>(receiveTreePickerData<LinkResource>({
756 id: 'Public Favorites',
758 data: items.filter(item => {
759 if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
765 extractNodeData: item => ({
768 status: item.headKind === ResourceKind.PROJECT
769 ? TreeNodeStatus.INITIAL
770 : includeDirectories || includeFiles
771 ? TreeNodeStatus.INITIAL
772 : TreeNodeStatus.LOADED
777 export const receiveTreePickerProjectsData = (id: string, projects: ProjectResource[], pickerId: string) =>
778 (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
779 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
781 nodes: projects.map(project => initTreeNode({ id: project.uuid, value: project })),
785 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
788 export const loadProjectTreePickerProjects = (id: string) =>
789 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
790 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PROJECTS }));
793 const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
794 const { items } = await services.projectService.list(buildParams(ownerUuid));
796 dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
799 export const loadFavoriteTreePickerProjects = (id: string) =>
800 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
801 const parentId = getUserUuid(getState()) || '';
804 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.FAVORITES }));
805 const { items } = await services.favoriteService.list(parentId);
806 dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
808 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.FAVORITES }));
809 const { items } = await services.projectService.list(buildParams(id));
810 dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.FAVORITES));
815 export const loadPublicFavoriteTreePickerProjects = (id: string) =>
816 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
817 const parentId = getUserUuid(getState()) || '';
820 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.PUBLIC_FAVORITES }));
821 const { items } = await services.favoriteService.list(parentId);
822 dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.PUBLIC_FAVORITES));
824 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PUBLIC_FAVORITES }));
825 const { items } = await services.projectService.list(buildParams(id));
826 dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PUBLIC_FAVORITES));
831 const buildParams = (ownerUuid: string) => {
833 filters: new FilterBuilder()
834 .addEqual('owner_uuid', ownerUuid)
836 order: new OrderBuilder<ProjectResource>()
843 * Given a tree picker item, return collection uuid and path
844 * if the item represents a valid target/destination location
846 export type FileOperationLocation = {
852 export const getFileOperationLocation = (item: ProjectsTreePickerItem) =>
853 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<FileOperationLocation | undefined> => {
854 if ('kind' in item && item.kind === ResourceKind.COLLECTION) {
858 pdh: item.portableDataHash,
861 } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
862 const uuid = getCollectionResourceCollectionUuid(item.id);
864 const collection = getResource<CollectionResource>(uuid)(getState().resources);
866 const itemPath = [item.path, item.name].join('/');
871 pdh: collection.portableDataHash,
881 * Create an expanded tree picker subtree from array of nested projects/collection
882 * First item is assumed to be root and gets empty parent id
883 * Nodes must be sorted from top down to prevent orphaned nodes
885 export const createInitialPickerTree = (sortedAncestors: Array<GroupResource | CollectionResource>, tailUuid: string, initialTree: Tree<GroupResource | CollectionResource>) => {
886 return sortedAncestors
887 .reduce((tree, item, index) => {
888 if (getNode(item.uuid)(tree)) {
894 parent: index === 0 ? '' : item.ownerUuid,
899 status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
905 export const fileOperationLocationToPickerId = (location: FileOperationLocation): string => {
906 let id = location.uuid;
907 if (location.subpath.length && location.subpath !== '/') {
908 id = id + location.subpath;