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, getNodeDescendantsIds } 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, select, all } 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 }>(),
63 export type TreePickerSearchAction = UnionOf<typeof treePickerSearchActions>;
65 export const treePickerSearchSagas = unionize({
66 SET_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(),
67 SET_COLLECTION_FILTER: ofType<{ pickerMainId: string, collectionFilterValue: string }>(),
68 APPLY_COLLECTION_FILTER: ofType<{ pickerId: string }>(),
69 LOAD_PROJECT: ofType<LoadProjectParamsWithId>(),
70 LOAD_SEARCH: ofType<LoadProjectParamsWithId>(),
71 LOAD_FAVORITES_PROJECT: ofType<LoadFavoritesProjectParams>(),
72 LOAD_PUBLIC_FAVORITES_PROJECT: ofType<LoadFavoritesProjectParams>(),
73 REFRESH_TREE_PICKER: ofType<{ pickerId: string }>(),
76 export function* setTreePickerProjectSearchWatcher() {
77 // Race conditions are handled in loadSearchWatcher so takeEvery is used here to avoid confusion
78 yield takeEvery(treePickerSearchSagas.tags.SET_PROJECT_SEARCH, setTreePickerProjectSearchSaga);
81 function* setTreePickerProjectSearchSaga({type, payload}: {
82 type: typeof treePickerSearchSagas.tags.SET_PROJECT_SEARCH,
83 payload: typeof treePickerSearchSagas._Record.SET_PROJECT_SEARCH,
86 const { pickerId , projectSearchValue } = payload;
87 const state: RootState = yield select();
88 const searchChanged = state.treePickerSearch.projectSearchValues[pickerId] !== projectSearchValue;
91 yield put(treePickerSearchActions.SET_TREE_PICKER_PROJECT_SEARCH(payload));
92 const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(state.treePicker);
94 const loadParams = state.treePickerSearch.loadProjectParams[pickerId];
95 // Put is non-blocking so race-condition prevention is handled by the loadSearchWatcher
96 yield put(treePickerSearchSagas.LOAD_SEARCH({
98 id: SEARCH_PROJECT_ID,
104 yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to search`, kind: SnackbarKind.ERROR }));
109 * Race-free collection filter saga as long as it's invoked through SET_COLLECTION_FILTER
111 export function* setTreePickerCollectionFilterWatcher() {
112 yield takeLatest(treePickerSearchSagas.tags.SET_COLLECTION_FILTER, setTreePickerCollectionFilterSaga);
115 function* setTreePickerCollectionFilterSaga({type, payload}: {
116 type: typeof treePickerSearchSagas.tags.SET_COLLECTION_FILTER,
117 payload: typeof treePickerSearchSagas._Record.SET_COLLECTION_FILTER,
120 const state: RootState = yield select();
121 const { pickerMainId , collectionFilterValue } = payload;
122 const pickerRootItemIds = Object.values(getProjectsTreePickerIds(pickerMainId));
124 const changedRootItemIds = pickerRootItemIds.filter((pickerRootId) =>
125 state.treePickerSearch.collectionFilterValues[pickerRootId] !== collectionFilterValue
128 yield all(pickerRootItemIds.map(pickerId =>
129 put(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({
131 collectionFilterValue,
135 yield all(changedRootItemIds.map(pickerId =>
136 call(applyCollectionFilterSaga, {
137 type: treePickerSearchSagas.tags.APPLY_COLLECTION_FILTER,
138 payload: { pickerId }
142 yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to search`, kind: SnackbarKind.ERROR }));
144 // Optionally handle cleanup when task cancelled
145 // if (yield cancelled()) {}
150 * Only meant to be called synchronously via call from other sagas that implement takeLatest
152 function* applyCollectionFilterSaga({type, payload}: {
153 type: typeof treePickerSearchSagas.tags.APPLY_COLLECTION_FILTER,
154 payload: typeof treePickerSearchSagas._Record.APPLY_COLLECTION_FILTER,
157 const state: RootState = yield select();
158 const { pickerId } = payload;
159 if (state.treePickerSearch.projectSearchValues[pickerId] !== "") {
160 yield call(refreshTreePickerSaga, {
161 type: treePickerSearchSagas.tags.REFRESH_TREE_PICKER,
162 payload: { pickerId }
165 const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(state.treePicker);
167 const loadParams = state.treePickerSearch.loadProjectParams[pickerId];
168 yield call(loadProjectSaga, {
169 type: treePickerSearchSagas.tags.LOAD_PROJECT,
172 id: SEARCH_PROJECT_ID,
178 yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to search`, kind: SnackbarKind.ERROR }));
182 export const getProjectsTreePickerIds = (pickerId: string) => ({
183 home: `${pickerId}_home`,
184 shared: `${pickerId}_shared`,
185 favorites: `${pickerId}_favorites`,
186 publicFavorites: `${pickerId}_publicFavorites`,
187 search: `${pickerId}_search`,
190 export const SEARCH_PROJECT_ID_PREFIX = "search-";
192 export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
194 () => values(getProjectsTreePickerIds(pickerId)),
197 .map(id => getTreePicker<Value>(id)(state)),
200 .map(getNodeDescendants(''))
201 .reduce((allNodes, nodes) => allNodes.concat(nodes), []),
204 .reduce((map, node) =>
206 ? map.set(node.id, node)
207 : map, new Map<string, TreeNode<Value>>())
210 uniqueNodes => Array.from(uniqueNodes),
212 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
213 getAllNodes<Value>(pickerId, node => node.selected)(state);
215 interface TreePickerPreloadParams {
216 selectedItemUuids: string[];
217 includeDirectories: boolean;
218 includeFiles: boolean;
222 export const initProjectsTreePicker = (pickerId: string, preloadParams?: TreePickerPreloadParams) =>
223 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
224 const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
225 dispatch<any>(initUserProject(home));
226 dispatch<any>(initSharedProject(shared));
227 dispatch<any>(initFavoritesProject(favorites));
228 dispatch<any>(initPublicFavoritesProject(publicFavorites));
229 dispatch<any>(initSearchProject(search));
231 if (preloadParams && preloadParams.selectedItemUuids.length) {
232 await dispatch<any>(loadInitialValue(
233 preloadParams.selectedItemUuids,
235 preloadParams.includeDirectories,
236 preloadParams.includeFiles,
242 interface ReceiveTreePickerDataParams<T> {
244 extractNodeData: (value: T) => { id: string, value: T, status?: TreeNodeStatus };
249 export const receiveTreePickerData = <T>(params: ReceiveTreePickerDataParams<T>) =>
250 (dispatch: Dispatch) => {
251 const { data, extractNodeData, id, pickerId, } = params;
252 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
254 nodes: data.map(item => initTreeNode(extractNodeData(item))),
257 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
260 export const extractGroupContentsNodeData = (expandableCollections: boolean) => (item: GroupContentsResource & TreeItemWithWeight) => {
261 if (item.uuid === "more-items-available") {
265 status: TreeNodeStatus.LOADED
267 } else if (item.weight === TreeItemWeight.LIGHT) {
269 id: SEARCH_PROJECT_ID_PREFIX+item.uuid,
271 status: item.kind === ResourceKind.PROJECT
272 ? TreeNodeStatus.INITIAL
273 : item.kind === ResourceKind.COLLECTION && expandableCollections
274 ? TreeNodeStatus.INITIAL
275 : TreeNodeStatus.LOADED
278 return { id: item.uuid,
280 status: item.kind === ResourceKind.PROJECT
281 ? TreeNodeStatus.INITIAL
282 : item.kind === ResourceKind.COLLECTION && expandableCollections
283 ? TreeNodeStatus.INITIAL
284 : TreeNodeStatus.LOADED
289 interface LoadProjectParamsWithId extends LoadProjectParams {
292 loadShared?: boolean;
296 * Kicks off a picker search load that allows paralell runs
297 * Used for expanding nodes
299 export const loadProject = (params: LoadProjectParamsWithId) => (treePickerSearchSagas.LOAD_PROJECT(params));
300 export function* loadProjectWatcher() {
301 yield takeEvery(treePickerSearchSagas.tags.LOAD_PROJECT, loadProjectSaga);
305 * Asynchronously kicks off a race-free picker search load - does not block when used this way
307 export const loadSearch = (params: LoadProjectParamsWithId) => (treePickerSearchSagas.LOAD_SEARCH(params));
308 export function* loadSearchWatcher() {
309 yield takeLatest(treePickerSearchSagas.tags.LOAD_SEARCH, loadProjectSaga);
313 * loadProjectSaga is used to load or refresh a project node in a tree picker
314 * Errors are caught and a toast is shown if the project fails to load
315 * Blocks when called directly with call(), can be composed into race-free groups
317 function* loadProjectSaga({type, payload}: {
318 type: typeof treePickerSearchSagas.tags.LOAD_PROJECT,
319 payload: typeof treePickerSearchSagas._Record.LOAD_PROJECT,
322 const services: ServiceRepository = yield getContext("services");
323 const state: RootState = yield select();
328 includeCollections = false,
329 includeDirectories = false,
330 includeFiles = false,
331 includeFilterGroups = false,
336 const searching = (id === SEARCH_PROJECT_ID);
337 const collectionFilter = state.treePickerSearch.collectionFilterValues[pickerId];
338 const projectFilter = state.treePickerSearch.projectSearchValues[pickerId];
340 let filterB = new FilterBuilder();
342 let includeOwners: string|undefined = undefined;
344 if (id.startsWith(SEARCH_PROJECT_ID_PREFIX)) {
349 // opening top level search
351 includeOwners = "owner_uuid";
352 filterB = filterB.addIsA('uuid', [ResourceKind.PROJECT]);
354 const objtype = extractUuidObjectType(projectFilter);
355 if (objtype === ResourceObjectType.GROUP || objtype === ResourceObjectType.USER) {
356 filterB = filterB.addEqual('uuid', projectFilter);
359 filterB = filterB.addFullTextSearch(projectFilter, 'groups');
362 } else if (collectionFilter) {
363 includeOwners = "owner_uuid";
364 filterB = filterB.addIsA('uuid', [ResourceKind.COLLECTION]);
366 const objtype = extractUuidObjectType(collectionFilter);
367 if (objtype === ResourceObjectType.COLLECTION) {
368 filterB = filterB.addEqual('uuid', collectionFilter);
369 } else if (COLLECTION_PDH_REGEX.exec(collectionFilter)) {
370 filterB = filterB.addEqual('portable_data_hash', collectionFilter);
372 filterB = filterB.addFullTextSearch(collectionFilter, 'collections');
378 // opening a folder below the top level
379 if (includeCollections) {
380 filterB = filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION]);
382 filterB = filterB.addIsA('uuid', [ResourceKind.PROJECT]);
386 filterB = filterB.addFullTextSearch(projectFilter, 'groups');
388 if (collectionFilter) {
389 filterB = filterB.addFullTextSearch(collectionFilter, 'collections');
393 filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
395 const globalSearch = loadShared || id === SEARCH_PROJECT_ID;
397 const filters = filterB.getFilters();
399 // Must be under 1000
400 const itemLimit = 200;
402 yield put(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
404 let { items, included } = yield call(
405 {context: services.groupsService, fn: services.groupsService.contents},
406 globalSearch ? '' : id,
409 excludeHomeProject: loadShared || undefined,
412 include: includeOwners,
417 includeOwners = undefined;
420 //let rootItems: GroupContentsResource[] | GroupContentsIncludedResource[] = items;
421 let rootItems: any[] = items;
425 if (includeOwners && included) {
426 included = included.filter(item => {
427 if (seen.hasOwnProperty(item.uuid)) {
430 seen[item.uuid] = item;
434 yield put(updateResources(included));
436 rootItems = included;
439 items = items.filter(item => {
440 if (seen.hasOwnProperty(item.uuid)) {
443 seen[item.uuid] = item;
444 if (!seen[item.ownerUuid] && includeOwners) {
445 rootItems.push(item);
450 yield put(updateResources(items));
452 if (items.length > itemLimit) {
454 uuid: "more-items-available-"+id,
455 kind: ResourceKind.WORKFLOW,
456 name: `*** Not all items listed, reduce item count with search or filter ***`,
461 modifiedByUserUuid: "",
468 yield put(receiveTreePickerData<GroupContentsResource>({
471 data: rootItems.filter(item => {
472 if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
476 if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
481 if (extractUuidObjectType(item.uuid) === ResourceObjectType.USER) {
484 name: item['fullName'] + " Home Project",
485 weight: includeOwners ? TreeItemWeight.LIGHT : TreeItemWeight.NORMAL,
486 kind: ResourceKind.USER,
491 weight: includeOwners ? TreeItemWeight.LIGHT : TreeItemWeight.NORMAL,};
494 extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
498 // Searching, we already have the
499 // contents to put in the owner projects so load it up.
501 items.forEach(item => {
502 if (!projects.hasOwnProperty(item.ownerUuid)) {
503 projects[item.ownerUuid] = [];
505 projects[item.ownerUuid].push({...item, weight: TreeItemWeight.DARK});
507 for (const prj in projects) {
508 yield put(receiveTreePickerData<GroupContentsResource>({
509 id: SEARCH_PROJECT_ID_PREFIX+prj,
512 extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
517 console.error("Failed to load project into tree picker:", e);;
518 yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load project`, kind: SnackbarKind.ERROR }));
520 // Optionally handle cleanup when task cancelled
521 // if (yield cancelled()) {}
525 export const refreshTreePicker = (params: typeof treePickerSearchSagas._Record.REFRESH_TREE_PICKER) => (treePickerSearchSagas.REFRESH_TREE_PICKER(params));
527 export function* refreshTreePickerWatcher() {
528 yield takeEvery(treePickerSearchSagas.tags.REFRESH_TREE_PICKER, refreshTreePickerSaga);
532 * Refreshes a single tree picker subtree
534 function* refreshTreePickerSaga({type, payload}: {
535 type: typeof treePickerSearchSagas.tags.REFRESH_TREE_PICKER,
536 payload: typeof treePickerSearchSagas._Record.REFRESH_TREE_PICKER,
539 const state: RootState = yield select();
540 const { pickerId } = payload;
542 const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(state.treePicker);
544 const loadParams = state.treePickerSearch.loadProjectParams[pickerId];
545 yield all((getNodeDescendantsIds('')(picker)
546 .reduce((acc, id) => {
547 const node = getNode(id)(picker);
548 if (node && node.status !== TreeNodeStatus.INITIAL) {
549 if (node.id.substring(6, 11) === 'tpzed' || node.id.substring(6, 11) === 'j7d0g') {
550 return acc.concat(call(loadProjectSaga, {
551 type: treePickerSearchSagas.tags.LOAD_PROJECT,
558 if (node.id === SHARED_PROJECT_ID) {
559 return acc.concat(call(loadProjectSaga, {
560 type: treePickerSearchSagas.tags.LOAD_PROJECT,
568 if (node.id === SEARCH_PROJECT_ID) {
569 return acc.concat(call(loadProjectSaga, {
570 type: treePickerSearchSagas.tags.LOAD_PROJECT,
577 if (node.id === FAVORITES_PROJECT_ID) {
578 return acc.concat(call(loadFavoritesProjectSaga, {
579 type: treePickerSearchSagas.tags.LOAD_FAVORITES_PROJECT,
585 if (node.id === PUBLIC_FAVORITES_PROJECT_ID) {
586 return acc.concat(call(loadPublicFavoritesProjectSaga, {
587 type: treePickerSearchSagas.tags.LOAD_PUBLIC_FAVORITES_PROJECT,
595 }, [] as Object[])));
598 yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to search`, kind: SnackbarKind.ERROR }));
602 export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) =>
603 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
604 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
606 const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
609 const node = getNode(id)(picker);
610 if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
611 const files = (await services.collectionService.files(node.value.uuid))
614 (includeDirectories && file.type === CollectionFileType.DIRECTORY)
616 const tree = createCollectionFilesTree(files);
617 const sorted = sortFilesTree(tree);
618 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
620 // await tree modifications so that consumers can guarantee node presence
622 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
625 subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
628 // Expand collection root node
629 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
634 export const HOME_PROJECT_ID = 'Home Projects';
635 export const initUserProject = (pickerId: string) =>
636 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
637 const uuid = getUserUuid(getState());
639 dispatch(receiveTreePickerData({
642 data: [{ uuid, name: HOME_PROJECT_ID }],
643 extractNodeData: value => ({
645 status: TreeNodeStatus.INITIAL,
651 export const loadUserProject = (pickerId: string, includeCollections = false, includeDirectories = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
652 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
653 const uuid = getUserUuid(getState());
655 dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
659 export const SHARED_PROJECT_ID = 'Shared with me';
660 export const initSharedProject = (pickerId: string) =>
661 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
662 dispatch(receiveTreePickerData({
665 data: [{ uuid: SHARED_PROJECT_ID, name: SHARED_PROJECT_ID }],
666 extractNodeData: value => ({
668 status: TreeNodeStatus.INITIAL,
674 type PickerItemPreloadData = {
676 mainItemUuid: string;
677 ancestors: (GroupResource | CollectionResource)[];
678 isHomeProjectItem: boolean;
681 type PickerTreePreloadData = {
682 tree: Tree<GroupResource | CollectionResource>;
683 pickerTreeId: string;
684 pickerTreeRootUuid: string;
687 export const loadInitialValue = (pickerItemIds: string[], pickerId: string, includeDirectories: boolean, includeFiles: boolean, multi: boolean,) =>
688 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
689 const homeUuid = getUserUuid(getState());
691 // Request ancestor trees in paralell and save home project status
692 const pickerItemsData: PickerItemPreloadData[] = await Promise.allSettled(pickerItemIds.map(async itemId => {
693 const mainItemUuid = itemId.includes('/') ? itemId.split('/')[0] : itemId;
695 const ancestors = (await services.ancestorsService.ancestors(mainItemUuid, ''))
697 item.kind === ResourceKind.GROUP ||
698 item.kind === ResourceKind.COLLECTION
699 ) as (GroupResource | CollectionResource)[];
701 if (ancestors.length === 0) {
702 return Promise.reject({item: itemId});
705 const isHomeProjectItem = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid));
714 // Show toast if any selections failed to restore
715 const rejectedPromises = res.filter((promiseResult): promiseResult is PromiseRejectedResult => (promiseResult.status === 'rejected'));
716 if (rejectedPromises.length) {
717 rejectedPromises.forEach(item => {
718 console.error("The following item failed to load into the tree picker", item.reason);
720 dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Some selections failed to load and were removed. See console for details.`, kind: SnackbarKind.ERROR }));
722 // Filter out any failed promises and map to resulting preload data with ancestors
723 return res.filter((promiseResult): promiseResult is PromiseFulfilledResult<PickerItemPreloadData> => (
724 promiseResult.status === 'fulfilled'
725 )).map(res => res.value)
728 // Group items to preload / ancestor data by home/shared picker and create initial Trees to preload
729 const initialTreePreloadData: PickerTreePreloadData[] = [
730 pickerItemsData.filter((item) => item.isHomeProjectItem),
731 pickerItemsData.filter((item) => !item.isHomeProjectItem),
733 .filter((items) => items.length > 0)
736 (preloadTree, itemData) => ({
737 tree: createInitialPickerTree(
739 itemData.mainItemUuid,
742 pickerTreeId: getPickerItemTreeId(itemData, homeUuid, pickerId),
743 pickerTreeRootUuid: getPickerItemRootUuid(itemData, homeUuid),
746 tree: createTree<GroupResource | CollectionResource>(),
748 pickerTreeRootUuid: '',
749 } as PickerTreePreloadData
753 // Load initial trees into corresponding picker store
754 await Promise.all(initialTreePreloadData.map(preloadTree => (
756 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
757 id: preloadTree.pickerTreeRootUuid,
758 pickerId: preloadTree.pickerTreeId,
759 subtree: preloadTree.tree,
764 // Await loading collection before attempting to select items
765 await Promise.all(pickerItemsData.map(async itemData => {
766 const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
768 // Selected item resides in collection subpath
769 if (itemData.itemId.includes('/')) {
770 // Load collection into tree
771 // loadCollection includes more than dispatched actions and must be awaited
772 await dispatch(loadCollection(itemData.mainItemUuid, pickerTreeId, includeDirectories, includeFiles));
774 // Expand nodes down to destination
775 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE_ANCESTORS({ id: itemData.itemId, pickerId: pickerTreeId }));
778 // Select or activate nodes
779 pickerItemsData.forEach(itemData => {
780 const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
783 dispatch(treePickerActions.SELECT_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId, cascade: false}));
785 dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId }));
789 // Refresh triggers loading in all adjacent items that were not included in the ancestor tree
790 await initialTreePreloadData.map(preloadTree => dispatch(treePickerSearchSagas.REFRESH_TREE_PICKER({ pickerId: preloadTree.pickerTreeId })));
793 const getPickerItemTreeId = (itemData: PickerItemPreloadData, homeUuid: string | undefined, pickerId: string) => {
794 const { home, shared } = getProjectsTreePickerIds(pickerId);
795 return ((itemData.isHomeProjectItem && homeUuid) ? home : shared);
798 const getPickerItemRootUuid = (itemData: PickerItemPreloadData, homeUuid: string | undefined) => {
799 return (itemData.isHomeProjectItem && homeUuid) ? homeUuid : SHARED_PROJECT_ID;
802 export const FAVORITES_PROJECT_ID = 'Favorites';
803 export const initFavoritesProject = (pickerId: string) =>
804 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
805 dispatch(receiveTreePickerData({
808 data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
809 extractNodeData: value => ({
811 status: TreeNodeStatus.INITIAL,
817 export const PUBLIC_FAVORITES_PROJECT_ID = 'Public Favorites';
818 export const initPublicFavoritesProject = (pickerId: string) =>
819 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
820 dispatch(receiveTreePickerData({
823 data: [{ uuid: PUBLIC_FAVORITES_PROJECT_ID, name: PUBLIC_FAVORITES_PROJECT_ID }],
824 extractNodeData: value => ({
826 status: TreeNodeStatus.INITIAL,
832 export const SEARCH_PROJECT_ID = 'Search all Projects';
833 export const initSearchProject = (pickerId: string) =>
834 async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
835 dispatch(receiveTreePickerData({
838 data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
839 extractNodeData: value => ({
841 status: TreeNodeStatus.INITIAL,
848 interface LoadFavoritesProjectParams {
850 includeCollections?: boolean;
851 includeDirectories?: boolean;
852 includeFiles?: boolean;
853 options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
856 export const loadFavoritesProject = (params: typeof treePickerSearchSagas._Record.LOAD_FAVORITES_PROJECT) => (treePickerSearchSagas.LOAD_FAVORITES_PROJECT(params));
858 export function* loadFavoritesProjectWatcher() {
859 yield takeEvery(treePickerSearchSagas.tags.LOAD_FAVORITES_PROJECT, loadFavoritesProjectSaga);
862 function* loadFavoritesProjectSaga({type, payload}: {
863 type: typeof treePickerSearchSagas.tags.LOAD_FAVORITES_PROJECT,
864 payload: typeof treePickerSearchSagas._Record.LOAD_FAVORITES_PROJECT,
867 const services: ServiceRepository = yield getContext("services");
868 const state: RootState = yield select();
872 includeCollections = false,
873 includeDirectories = false,
874 includeFiles = false,
875 options = { showOnlyOwned: true, showOnlyWritable: false },
877 const uuid = getUserUuid(state);
879 const filters = pipe(
880 (fb: FilterBuilder) => includeCollections
881 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
882 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
883 fb => fb.getFilters(),
884 )(new FilterBuilder());
886 const { items } = yield call(
887 {context: services.favoriteService, fn: services.favoriteService.list},
890 options.showOnlyOwned,
893 yield put(receiveTreePickerData<GroupContentsResource>({
896 data: items.filter((item) => {
897 if (options.showOnlyWritable && !(item as GroupResource).canWrite) {
901 if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
907 extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
911 yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load favorites`, kind: SnackbarKind.ERROR }));
915 export const loadPublicFavoritesProject = (params: typeof treePickerSearchSagas._Record.LOAD_PUBLIC_FAVORITES_PROJECT) => (treePickerSearchSagas.LOAD_PUBLIC_FAVORITES_PROJECT(params));
917 export function* loadPublicFavoritesProjectWatcher() {
918 yield takeEvery(treePickerSearchSagas.tags.LOAD_PUBLIC_FAVORITES_PROJECT, loadPublicFavoritesProjectSaga);
921 function* loadPublicFavoritesProjectSaga({type, payload}: {
922 type: typeof treePickerSearchSagas.tags.LOAD_PUBLIC_FAVORITES_PROJECT,
923 payload: typeof treePickerSearchSagas._Record.LOAD_PUBLIC_FAVORITES_PROJECT,
926 const services: ServiceRepository = yield getContext("services");
927 const state: RootState = yield select();
929 const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false, options } = payload;
930 const uuidPrefix = state.auth.config.uuidPrefix;
931 const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
934 // favorites and public favorites ought to use a single method
935 // after getting back a list of links, need to look and stash the resources
937 const filters = pipe(
938 (fb: FilterBuilder) => includeCollections
939 ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
940 : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
942 .addEqual('link_class', LinkClass.STAR)
943 .addEqual('owner_uuid', publicProjectUuid)
945 )(new FilterBuilder());
947 const { items } = yield call(
948 {context: services.linkService, fn: services.linkService.list},
952 yield put(receiveTreePickerData<LinkResource>({
953 id: 'Public Favorites',
955 data: items.filter(item => {
956 if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
962 extractNodeData: item => ({
965 status: item.headKind === ResourceKind.PROJECT
966 ? TreeNodeStatus.INITIAL
967 : includeDirectories || includeFiles
968 ? TreeNodeStatus.INITIAL
969 : TreeNodeStatus.LOADED
973 yield put(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load public favorites`, kind: SnackbarKind.ERROR }));
977 export const receiveTreePickerProjectsData = (id: string, projects: ProjectResource[], pickerId: string) =>
978 (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
979 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
981 nodes: projects.map(project => initTreeNode({ id: project.uuid, value: project })),
985 dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
988 export const loadProjectTreePickerProjects = (id: string) =>
989 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
990 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PROJECTS }));
993 const ownerUuid = id.length === 0 ? getUserUuid(getState()) || '' : id;
994 const { items } = await services.projectService.list(buildParams(ownerUuid));
996 dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PROJECTS));
999 export const loadFavoriteTreePickerProjects = (id: string) =>
1000 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
1001 const parentId = getUserUuid(getState()) || '';
1004 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.FAVORITES }));
1005 const { items } = await services.favoriteService.list(parentId);
1006 dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
1008 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.FAVORITES }));
1009 const { items } = await services.projectService.list(buildParams(id));
1010 dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.FAVORITES));
1015 export const loadPublicFavoriteTreePickerProjects = (id: string) =>
1016 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
1017 const parentId = getUserUuid(getState()) || '';
1020 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: parentId, pickerId: TreePickerId.PUBLIC_FAVORITES }));
1021 const { items } = await services.favoriteService.list(parentId);
1022 dispatch<any>(receiveTreePickerProjectsData(parentId, items as ProjectResource[], TreePickerId.PUBLIC_FAVORITES));
1024 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId: TreePickerId.PUBLIC_FAVORITES }));
1025 const { items } = await services.projectService.list(buildParams(id));
1026 dispatch<any>(receiveTreePickerProjectsData(id, items, TreePickerId.PUBLIC_FAVORITES));
1031 const buildParams = (ownerUuid: string) => {
1033 filters: new FilterBuilder()
1034 .addEqual('owner_uuid', ownerUuid)
1036 order: new OrderBuilder<ProjectResource>()
1043 * Given a tree picker item, return collection uuid and path
1044 * if the item represents a valid target/destination location
1046 export type FileOperationLocation = {
1052 export const getFileOperationLocation = (item: ProjectsTreePickerItem) =>
1053 async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<FileOperationLocation | undefined> => {
1054 if ('kind' in item && item.kind === ResourceKind.COLLECTION) {
1058 pdh: item.portableDataHash,
1061 } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
1062 const uuid = getCollectionResourceCollectionUuid(item.id);
1064 const collection = getResource<CollectionResource>(uuid)(getState().resources);
1066 const itemPath = [item.path, item.name].join('/');
1071 pdh: collection.portableDataHash,
1081 * Create an expanded tree picker subtree from array of nested projects/collection
1082 * First item is assumed to be root and gets empty parent id
1083 * Nodes must be sorted from top down to prevent orphaned nodes
1085 export const createInitialPickerTree = (sortedAncestors: Array<GroupResource | CollectionResource>, tailUuid: string, initialTree: Tree<GroupResource | CollectionResource>) => {
1086 return sortedAncestors
1087 .reduce((tree, item, index) => {
1088 if (getNode(item.uuid)(tree)) {
1094 parent: index === 0 ? '' : item.ownerUuid,
1099 status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
1105 export const fileOperationLocationToPickerId = (location: FileOperationLocation): string => {
1106 let id = location.uuid;
1107 if (location.subpath.length && location.subpath !== '/') {
1108 id = id + location.subpath;