X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/df1ebc0e3184afd3fb66414651fc1aec713928bf..0b569ee693ece680309ac9c114e975c9cb3e7f64:/services/workbench2/src/store/tree-picker/tree-picker-actions.ts diff --git a/services/workbench2/src/store/tree-picker/tree-picker-actions.ts b/services/workbench2/src/store/tree-picker/tree-picker-actions.ts index 72d1cb65d9..883847d854 100644 --- a/services/workbench2/src/store/tree-picker/tree-picker-actions.ts +++ b/services/workbench2/src/store/tree-picker/tree-picker-actions.ts @@ -23,6 +23,9 @@ import { mapTreeValues } from "models/tree"; import { sortFilesTree } from "services/collection-service/collection-service-files-response"; import { GroupClass, GroupResource } from "models/group"; import { CollectionResource } from "models/collection"; +import { getResource } from "store/resources/resources"; +import { updateResources } from "store/resources/resources-actions"; +import { SnackbarKind, snackbarActions } from "store/snackbar/snackbar-actions"; export const treePickerActions = unionize({ LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(), @@ -30,11 +33,12 @@ export const treePickerActions = unionize({ APPEND_TREE_PICKER_NODE_SUBTREE: ofType<{ id: string, subtree: Tree, pickerId: string }>(), TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string, pickerId: string }>(), EXPAND_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(), + EXPAND_TREE_PICKER_NODE_ANCESTORS: ofType<{ id: string, pickerId: string }>(), ACTIVATE_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string, relatedTreePickers?: string[] }>(), DEACTIVATE_TREE_PICKER_NODE: ofType<{ pickerId: string }>(), - TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string }>(), - SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(), - DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(), + TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string, cascade: boolean }>(), + SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(), + DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(), EXPAND_TREE_PICKER_NODES: ofType<{ ids: string[], pickerId: string }>(), RESET_TREE_PICKER: ofType<{ pickerId: string }>() }); @@ -89,7 +93,14 @@ export const getAllNodes = (pickerId: string, filter = (node: TreeNode(pickerId: string) => (state: TreePicker) => getAllNodes(pickerId, node => node.selected)(state); -export const initProjectsTreePicker = (pickerId: string, selectedItemUuid?: string) => +interface TreePickerPreloadParams { + selectedItemUuids: string[]; + includeDirectories: boolean; + includeFiles: boolean; + multi: boolean; +} + +export const initProjectsTreePicker = (pickerId: string, preloadParams?: TreePickerPreloadParams) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId); dispatch(initUserProject(home)); @@ -98,8 +109,14 @@ export const initProjectsTreePicker = (pickerId: string, selectedItemUuid?: stri dispatch(initPublicFavoritesProject(publicFavorites)); dispatch(initSearchProject(search)); - if (selectedItemUuid) { - dispatch(loadInitialValue(selectedItemUuid, pickerId)); + if (preloadParams && preloadParams.selectedItemUuids.length) { + await dispatch(loadInitialValue( + preloadParams.selectedItemUuids, + pickerId, + preloadParams.includeDirectories, + preloadParams.includeFiles, + preloadParams.multi + )); } }; @@ -128,6 +145,10 @@ interface LoadProjectParamsWithId extends LoadProjectParams { searchProjects?: boolean; } +/** + * loadProject is used to load or refresh a project node in a tree picker + * Errors are caught and a toast is shown if the project fails to load + */ export const loadProject = (params: LoadProjectParamsWithId) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const { @@ -166,56 +187,62 @@ export const loadProject = (params: LoadProjectParamsWithId) => const itemLimit = 200; - const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit }); - - if (itemsAvailable > itemLimit) { - items.push({ - uuid: "more-items-available", - kind: ResourceKind.WORKFLOW, - name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`, - description: "", - definition: "", - ownerUuid: "", - createdAt: "", - modifiedByClientUuid: "", - modifiedByUserUuid: "", - modifiedAt: "", - href: "", - etag: "" - }); - } - - dispatch(receiveTreePickerData({ - id, - pickerId, - data: items.filter((item) => { - if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) { - return false; - } + try { + const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit }); + dispatch(updateResources(items)); + + if (itemsAvailable > itemLimit) { + items.push({ + uuid: "more-items-available", + kind: ResourceKind.WORKFLOW, + name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`, + description: "", + definition: "", + ownerUuid: "", + createdAt: "", + modifiedByClientUuid: "", + modifiedByUserUuid: "", + modifiedAt: "", + href: "", + etag: "" + }); + } - if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) { - return false; - } + dispatch(receiveTreePickerData({ + id, + pickerId, + data: items.filter((item) => { + if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) { + return false; + } - return true; - }), - extractNodeData: item => ( - item.uuid === "more-items-available" ? - { - id: item.uuid, - value: item, - status: TreeNodeStatus.LOADED + if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) { + return false; } - : { - id: item.uuid, - value: item, - status: item.kind === ResourceKind.PROJECT - ? TreeNodeStatus.INITIAL - : includeDirectories || includeFiles + + return true; + }), + extractNodeData: item => ( + item.uuid === "more-items-available" ? + { + id: item.uuid, + value: item, + status: TreeNodeStatus.LOADED + } + : { + id: item.uuid, + value: item, + status: item.kind === ResourceKind.PROJECT ? TreeNodeStatus.INITIAL - : TreeNodeStatus.LOADED - }), - })); + : includeDirectories || includeFiles + ? TreeNodeStatus.INITIAL + : TreeNodeStatus.LOADED + }), + })); + } catch(e) { + console.error("Failed to load project into tree picker:", e);; + dispatch(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load project`, kind: SnackbarKind.ERROR })); + } }; export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) => @@ -236,13 +263,15 @@ export const loadCollection = (id: string, pickerId: string, includeDirectories? const sorted = sortFilesTree(tree); const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted); - dispatch( + // await tree modifications so that consumers can guarantee node presence + await dispatch( treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({ id, pickerId, subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree) })); + // Expand collection root node dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId })); } } @@ -288,35 +317,134 @@ export const initSharedProject = (pickerId: string) => })); }; -export const loadInitialValue = (initialValue: string, pickerId: string) => +type PickerItemPreloadData = { + itemId: string; + mainItemUuid: string; + ancestors: (GroupResource | CollectionResource)[]; + isHomeProjectItem: boolean; +} + +type PickerTreePreloadData = { + tree: Tree; + pickerTreeId: string; + pickerTreeRootUuid: string; +}; + +export const loadInitialValue = (pickerItemIds: string[], pickerId: string, includeDirectories: boolean, includeFiles: boolean, multi: boolean,) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const { home, shared } = getProjectsTreePickerIds(pickerId); const homeUuid = getUserUuid(getState()); - const ancestors = (await services.ancestorsService.ancestors(initialValue, '')) + + // Request ancestor trees in paralell and save home project status + const pickerItemsData: PickerItemPreloadData[] = await Promise.allSettled(pickerItemIds.map(async itemId => { + const mainItemUuid = itemId.includes('/') ? itemId.split('/')[0] : itemId; + + const ancestors = (await services.ancestorsService.ancestors(mainItemUuid, '')) .filter(item => item.kind === ResourceKind.GROUP || item.kind === ResourceKind.COLLECTION ) as (GroupResource | CollectionResource)[]; - if (ancestors.length) { - const isUserHomeProject = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid)); - const pickerTreeId = isUserHomeProject ? home : shared; - const pickerTreeRootUuid: string = (homeUuid && isUserHomeProject) ? homeUuid : SHARED_PROJECT_ID; + if (ancestors.length === 0) { + return Promise.reject({item: itemId}); + } + + const isHomeProjectItem = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid)); - ancestors[0].ownerUuid = ''; - const tree = createInitialLocationTree(ancestors, initialValue); + return { + itemId, + mainItemUuid, + ancestors, + isHomeProjectItem, + }; + })).then((res) => { + // Show toast if any selections failed to restore + const rejectedPromises = res.filter((promiseResult): promiseResult is PromiseRejectedResult => (promiseResult.status === 'rejected')); + if (rejectedPromises.length) { + rejectedPromises.forEach(item => { + console.error("The following item failed to load into the tree picker", item.reason); + }); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: `Some selections failed to load and were removed. See console for details.`, kind: SnackbarKind.ERROR })); + } + // Filter out any failed promises and map to resulting preload data with ancestors + return res.filter((promiseResult): promiseResult is PromiseFulfilledResult => ( + promiseResult.status === 'fulfilled' + )).map(res => res.value) + }); + + // Group items to preload / ancestor data by home/shared picker and create initial Trees to preload + const initialTreePreloadData: PickerTreePreloadData[] = [ + pickerItemsData.filter((item) => item.isHomeProjectItem), + pickerItemsData.filter((item) => !item.isHomeProjectItem), + ] + .filter((items) => items.length > 0) + .map((itemGroup) => + itemGroup.reduce( + (preloadTree, itemData) => ({ + tree: createInitialPickerTree( + itemData.ancestors, + itemData.mainItemUuid, + preloadTree.tree + ), + pickerTreeId: getPickerItemTreeId(itemData, homeUuid, pickerId), + pickerTreeRootUuid: getPickerItemRootUuid(itemData, homeUuid), + }), + { + tree: createTree(), + pickerTreeId: '', + pickerTreeRootUuid: '', + } as PickerTreePreloadData + ) + ); + + // Load initial trees into corresponding picker store + await Promise.all(initialTreePreloadData.map(preloadTree => ( dispatch( treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({ - id: pickerTreeRootUuid, - pickerId: pickerTreeId, - subtree: tree - })); - dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: initialValue, pickerId: pickerTreeId })); - dispatch(treePickerSearchActions.REFRESH_TREE_PICKER({ pickerId: pickerTreeId })); - } + id: preloadTree.pickerTreeRootUuid, + pickerId: preloadTree.pickerTreeId, + subtree: preloadTree.tree, + }) + ) + ))); + + // Await loading collection before attempting to select items + await Promise.all(pickerItemsData.map(async itemData => { + const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId); + + // Selected item resides in collection subpath + if (itemData.itemId.includes('/')) { + // Load collection into tree + // loadCollection includes more than dispatched actions and must be awaited + await dispatch(loadCollection(itemData.mainItemUuid, pickerTreeId, includeDirectories, includeFiles)); + } + // Expand nodes down to destination + dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE_ANCESTORS({ id: itemData.itemId, pickerId: pickerTreeId })); + })); + // Select or activate nodes + pickerItemsData.forEach(itemData => { + const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId); + + if (multi) { + dispatch(treePickerActions.SELECT_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId, cascade: false})); + } else { + dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId })); + } + }); + + // Refresh triggers loading in all adjacent items that were not included in the ancestor tree + await initialTreePreloadData.map(preloadTree => dispatch(treePickerSearchActions.REFRESH_TREE_PICKER({ pickerId: preloadTree.pickerTreeId }))); } +const getPickerItemTreeId = (itemData: PickerItemPreloadData, homeUuid: string | undefined, pickerId: string) => { + const { home, shared } = getProjectsTreePickerIds(pickerId); + return ((itemData.isHomeProjectItem && homeUuid) ? home : shared); +}; + +const getPickerItemRootUuid = (itemData: PickerItemPreloadData, homeUuid: string | undefined) => { + return (itemData.isHomeProjectItem && homeUuid) ? homeUuid : SHARED_PROJECT_ID; +}; + export const FAVORITES_PROJECT_ID = 'Favorites'; export const initFavoritesProject = (pickerId: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { @@ -523,44 +651,68 @@ const buildParams = (ownerUuid: string) => { * if the item represents a valid target/destination location */ export type FileOperationLocation = { + name: string; uuid: string; - path: string; + pdh?: string; + subpath: string; } -export const getFileOperationLocation = (item: ProjectsTreePickerItem): FileOperationLocation | undefined => { - if ('kind' in item && item.kind === ResourceKind.COLLECTION) { - return { - uuid: item.uuid, - path: '/' - }; - } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) { - const uuid = getCollectionResourceCollectionUuid(item.id); - if (uuid) { +export const getFileOperationLocation = (item: ProjectsTreePickerItem) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise => { + if ('kind' in item && item.kind === ResourceKind.COLLECTION) { return { - uuid, - path: [item.path, item.name].join('/') + name: item.name, + uuid: item.uuid, + pdh: item.portableDataHash, + subpath: '/', }; - } else { - return undefined; + } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) { + const uuid = getCollectionResourceCollectionUuid(item.id); + if (uuid) { + const collection = getResource(uuid)(getState().resources); + if (collection) { + const itemPath = [item.path, item.name].join('/'); + + return { + name: item.name, + uuid, + pdh: collection.portableDataHash, + subpath: itemPath, + }; + } + } } - } else { return undefined; - } -}; + }; /** * Create an expanded tree picker subtree from array of nested projects/collection - * Assumes the root item of the subtree already has an empty string ownerUuid + * First item is assumed to be root and gets empty parent id + * Nodes must be sorted from top down to prevent orphaned nodes */ -export const createInitialLocationTree = (data: Array, tailUuid: string) => { - return data - .reduce((tree, item) => setNode({ - children: [], - id: item.uuid, - parent: item.ownerUuid, - value: item, - active: false, - selected: false, - expanded: false, - status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL, - })(tree), createTree()); +export const createInitialPickerTree = (sortedAncestors: Array, tailUuid: string, initialTree: Tree) => { + return sortedAncestors + .reduce((tree, item, index) => { + if (getNode(item.uuid)(tree)) { + return tree; + } else { + return setNode({ + children: [], + id: item.uuid, + parent: index === 0 ? '' : item.ownerUuid, + value: item, + active: false, + selected: false, + expanded: false, + status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL, + })(tree); + } + }, initialTree); }; + +export const fileOperationLocationToPickerId = (location: FileOperationLocation): string => { + let id = location.uuid; + if (location.subpath.length && location.subpath !== '/') { + id = id + location.subpath; + } + return id; +}