From 0de2dbbdaa1c0906e105cfc685affdb3d03dc9e7 Mon Sep 17 00:00:00 2001 From: Stephen Smith Date: Thu, 27 Apr 2023 10:36:19 -0400 Subject: [PATCH] 20031: Preselect current collection in move/copy to existing collection tree picker * Add collection support to ancestor service * Initialize copy/move to existing collection form with current collection * Use initial form value and ancestor service to preload tree picker with initial selection * Add refresh action to tree picker search actions to populate initial expanded tree picker items after preselection * Allow pristine copy/move to existing form to be submitted with initial value (relies on form validation to disable submit if no initial value) Arvados-DCO-1.1-Signed-off-by: Stephen Smith --- .../ancestors-service/ancestors-service.ts | 11 +- src/services/services.ts | 3 +- .../collection-partial-copy-actions.ts | 2 +- .../collection-partial-move-actions.ts | 4 +- src/store/tree-picker/tree-picker-actions.ts | 64 ++++++++++- .../tree-picker/tree-picker-middleware.ts | 104 +++++++++--------- ...on-partial-copy-to-existing-collection.tsx | 1 + ...on-partial-move-to-existing-collection.tsx | 1 + .../projects-tree-picker.tsx | 3 +- .../tree-picker-field.tsx | 1 + 10 files changed, 130 insertions(+), 64 deletions(-) diff --git a/src/services/ancestors-service/ancestors-service.ts b/src/services/ancestors-service/ancestors-service.ts index 90a0bf84..188c233e 100644 --- a/src/services/ancestors-service/ancestors-service.ts +++ b/src/services/ancestors-service/ancestors-service.ts @@ -7,18 +7,21 @@ import { UserService } from '../user-service/user-service'; import { GroupResource } from 'models/group'; import { UserResource } from 'models/user'; import { extractUuidObjectType, ResourceObjectType } from "models/resource"; +import { CollectionService } from "services/collection-service/collection-service"; +import { CollectionResource } from "models/collection"; export class AncestorService { constructor( private groupsService: GroupsService, - private userService: UserService + private userService: UserService, + private collectionService: CollectionService, ) { } - async ancestors(startUuid: string, endUuid: string): Promise> { + async ancestors(startUuid: string, endUuid: string): Promise> { return this._ancestors(startUuid, endUuid); } - private async _ancestors(startUuid: string, endUuid: string, previousUuid = ''): Promise> { + private async _ancestors(startUuid: string, endUuid: string, previousUuid = ''): Promise> { if (startUuid === previousUuid) { return []; @@ -49,6 +52,8 @@ export class AncestorService { return this.groupsService; case ResourceObjectType.USER: return this.userService; + case ResourceObjectType.COLLECTION: + return this.collectionService; default: return undefined; } diff --git a/src/services/services.ts b/src/services/services.ts index 4e4a682e..be6f16b6 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -75,13 +75,12 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient const workflowService = new WorkflowService(apiClient, actions); const linkAccountService = new LinkAccountService(apiClient, actions); - const ancestorsService = new AncestorService(groupsService, userService); - const idleTimeout = (config && config.clusterConfig && config.clusterConfig.Workbench.IdleTimeout) || '0s'; const authService = new AuthService(apiClient, config.rootUrl, actions, (parse(idleTimeout, 's') || 0) > 0); const collectionService = new CollectionService(apiClient, webdavClient, authService, actions); + const ancestorsService = new AncestorService(groupsService, userService, collectionService); const favoriteService = new FavoriteService(linkService, groupsService); const tagService = new TagService(linkService); const searchService = new SearchService(); diff --git a/src/store/collections/collection-partial-copy-actions.ts b/src/store/collections/collection-partial-copy-actions.ts index f9ecb5ca..5da7c8f1 100644 --- a/src/store/collections/collection-partial-copy-actions.ts +++ b/src/store/collections/collection-partial-copy-actions.ts @@ -109,7 +109,7 @@ export const openCollectionPartialCopyToExistingCollectionDialog = () => const currentCollection = getState().collectionPanel.item; if (currentCollection) { const initialData = { - destination: {uuid: '', destinationPath: ''} + destination: {uuid: currentCollection.uuid, destinationPath: ''} }; dispatch(initialize(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION, initialData)); dispatch(resetPickerProjectTree()); diff --git a/src/store/collections/collection-partial-move-actions.ts b/src/store/collections/collection-partial-move-actions.ts index 44e6a9bf..8b4492ef 100644 --- a/src/store/collections/collection-partial-move-actions.ts +++ b/src/store/collections/collection-partial-move-actions.ts @@ -105,7 +105,7 @@ export const openCollectionPartialMoveToExistingCollectionDialog = () => const currentCollection = getState().collectionPanel.item; if (currentCollection) { const initialData = { - destination: {uuid: '', path: ''} + destination: {uuid: currentCollection.uuid, path: ''} }; dispatch(initialize(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION, initialData)); dispatch(resetPickerProjectTree()); @@ -119,7 +119,7 @@ export const moveCollectionPartialToExistingCollection = ({ destination }: Colle // Get current collection const sourceCollection = state.collectionPanel.item; - if (sourceCollection && destination.uuid) { + if (sourceCollection && destination && destination.uuid) { try { dispatch(startSubmit(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION)); dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION)); diff --git a/src/store/tree-picker/tree-picker-actions.ts b/src/store/tree-picker/tree-picker-actions.ts index 505e0622..bf40394c 100644 --- a/src/store/tree-picker/tree-picker-actions.ts +++ b/src/store/tree-picker/tree-picker-actions.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0 import { unionize, ofType, UnionOf } from "common/unionize"; -import { TreeNode, initTreeNode, getNodeDescendants, TreeNodeStatus, getNode, TreePickerId, Tree } from 'models/tree'; +import { TreeNode, initTreeNode, getNodeDescendants, TreeNodeStatus, getNode, TreePickerId, Tree, setNode, createTree } from 'models/tree'; import { CollectionFileType, createCollectionFilesTree, getCollectionResourceCollectionUuid } from "models/collection-file"; import { Dispatch } from 'redux'; import { RootState } from 'store/store'; @@ -22,6 +22,7 @@ import { LinkResource, LinkClass } from "models/link"; 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"; export const treePickerActions = unionize({ LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(), @@ -52,6 +53,7 @@ export const treePickerSearchActions = unionize({ SET_TREE_PICKER_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(), SET_TREE_PICKER_COLLECTION_FILTER: ofType<{ pickerId: string, collectionFilterValue: string }>(), SET_TREE_PICKER_LOAD_PARAMS: ofType<{ pickerId: string, params: LoadProjectParams }>(), + REFRESH_TREE_PICKER: ofType<{ pickerId: string }>(), }); export type TreePickerSearchAction = UnionOf; @@ -87,14 +89,18 @@ export const getAllNodes = (pickerId: string, filter = (node: TreeNode(pickerId: string) => (state: TreePicker) => getAllNodes(pickerId, node => node.selected)(state); -export const initProjectsTreePicker = (pickerId: string) => - async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => { +export const initProjectsTreePicker = (pickerId: string, selectedItemUuid?: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId); dispatch(initUserProject(home)); dispatch(initSharedProject(shared)); dispatch(initFavoritesProject(favorites)); dispatch(initPublicFavoritesProject(publicFavorites)); dispatch(initSearchProject(search)); + + if (selectedItemUuid) { + dispatch(loadInitialValue(selectedItemUuid, pickerId)); + } }; interface ReceiveTreePickerDataParams { @@ -242,7 +248,7 @@ export const loadCollection = (id: string, pickerId: string, includeDirectories? } }; - +export const HOME_PROJECT_ID = 'Home Projects'; export const initUserProject = (pickerId: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const uuid = getUserUuid(getState()); @@ -250,7 +256,7 @@ export const initUserProject = (pickerId: string) => dispatch(receiveTreePickerData({ id: '', pickerId, - data: [{ uuid, name: 'Home Projects' }], + data: [{ uuid, name: HOME_PROJECT_ID }], extractNodeData: value => ({ id: value.uuid, status: TreeNodeStatus.INITIAL, @@ -282,6 +288,36 @@ export const initSharedProject = (pickerId: string) => })); }; +export const loadInitialValue = (initialValue: string, pickerId: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const { home, shared } = getProjectsTreePickerIds(pickerId); + const homeUuid = getUserUuid(getState()); + const ancestors = (await services.ancestorsService.ancestors(initialValue, '')) + .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; + + ancestors[0].ownerUuid = ''; + const tree = createInitialLocationTree(ancestors, initialValue); + dispatch( + treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({ + id: pickerTreeRootUuid, + pickerId: pickerTreeId, + subtree: tree + })); + dispatch(treePickerActions.EXPAND_TREE_PICKER_NODES({ ids: [pickerTreeRootUuid], pickerId: pickerTreeId })); + dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: initialValue, pickerId: pickerTreeId })); + dispatch(treePickerSearchActions.REFRESH_TREE_PICKER({ pickerId: pickerTreeId })); + } + + } + export const FAVORITES_PROJECT_ID = 'Favorites'; export const initFavoritesProject = (pickerId: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { @@ -511,3 +547,21 @@ export const getFileOperationLocation = (item: ProjectsTreePickerItem): FileOper 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 + */ +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: item.uuid !== tailUuid, + status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL, + })(tree), createTree()); +}; diff --git a/src/store/tree-picker/tree-picker-middleware.ts b/src/store/tree-picker/tree-picker-middleware.ts index 8fa3ee4a..6f748a99 100644 --- a/src/store/tree-picker/tree-picker-middleware.ts +++ b/src/store/tree-picker/tree-picker-middleware.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { Dispatch } from 'redux'; +import { Dispatch, MiddlewareAPI } from 'redux'; import { RootState } from 'store/store'; import { ServiceRepository } from 'services/services'; import { Middleware } from "redux"; @@ -37,6 +37,8 @@ export const treePickerSearchMiddleware: Middleware = store => next => action => isSearchAction = true; searchChanged = store.getState().treePickerSearch.collectionFilterValues[pickerId] !== collectionFilterValue; }, + + REFRESH_TREE_PICKER: refreshPickers(store), default: () => { } }); @@ -62,57 +64,59 @@ export const treePickerSearchMiddleware: Middleware = store => next => action => } }), - SET_TREE_PICKER_COLLECTION_FILTER: ({ pickerId }) => - store.dispatch((dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const picker = getTreePicker(pickerId)(getState().treePicker); - if (picker) { - const loadParams = getState().treePickerSearch.loadProjectParams[pickerId]; - getNodeDescendantsIds('')(picker) - .map(id => { - const node = getNode(id)(picker); - if (node && node.status !== TreeNodeStatus.INITIAL) { - if (node.id.substring(6, 11) === 'tpzed' || node.id.substring(6, 11) === 'j7d0g') { - dispatch(loadProject({ - ...loadParams, - id: node.id, - pickerId: pickerId, - })); - } - if (node.id === SHARED_PROJECT_ID) { - dispatch(loadProject({ - ...loadParams, - id: node.id, - pickerId: pickerId, - loadShared: true - })); - } - if (node.id === SEARCH_PROJECT_ID) { - dispatch(loadProject({ - ...loadParams, - id: node.id, - pickerId: pickerId, - searchProjects: true - })); - } - if (node.id === FAVORITES_PROJECT_ID) { - dispatch(loadFavoritesProject({ - ...loadParams, - pickerId: pickerId, - })); - } - if (node.id === PUBLIC_FAVORITES_PROJECT_ID) { - dispatch(loadPublicFavoritesProject({ - ...loadParams, - pickerId: pickerId, - })); - } - } - return id; - }); - } - }), + SET_TREE_PICKER_COLLECTION_FILTER: refreshPickers(store), default: () => { } }); return r; } + +const refreshPickers = (store: MiddlewareAPI) => ({ pickerId }) => + store.dispatch((dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const picker = getTreePicker(pickerId)(getState().treePicker); + if (picker) { + const loadParams = getState().treePickerSearch.loadProjectParams[pickerId]; + getNodeDescendantsIds('')(picker) + .map(id => { + const node = getNode(id)(picker); + if (node && node.status !== TreeNodeStatus.INITIAL) { + if (node.id.substring(6, 11) === 'tpzed' || node.id.substring(6, 11) === 'j7d0g') { + dispatch(loadProject({ + ...loadParams, + id: node.id, + pickerId: pickerId, + })); + } + if (node.id === SHARED_PROJECT_ID) { + dispatch(loadProject({ + ...loadParams, + id: node.id, + pickerId: pickerId, + loadShared: true + })); + } + if (node.id === SEARCH_PROJECT_ID) { + dispatch(loadProject({ + ...loadParams, + id: node.id, + pickerId: pickerId, + searchProjects: true + })); + } + if (node.id === FAVORITES_PROJECT_ID) { + dispatch(loadFavoritesProject({ + ...loadParams, + pickerId: pickerId, + })); + } + if (node.id === PUBLIC_FAVORITES_PROJECT_ID) { + dispatch(loadPublicFavoritesProject({ + ...loadParams, + pickerId: pickerId, + })); + } + } + return id; + }); + } + }) diff --git a/src/views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection.tsx b/src/views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection.tsx index f6d4db21..eb95d1f2 100644 --- a/src/views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection.tsx +++ b/src/views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection.tsx @@ -18,6 +18,7 @@ export const DialogCollectionPartialCopyToExistingCollection = (props: DialogCol dialogTitle='Copy to existing collection' formFields={CollectionPartialCopyFields(props.pickerId)} submitLabel='Copy files' + enableWhenPristine {...props} />; diff --git a/src/views-components/dialog-move/dialog-collection-partial-move-to-existing-collection.tsx b/src/views-components/dialog-move/dialog-collection-partial-move-to-existing-collection.tsx index f95bd24f..5cd4996d 100644 --- a/src/views-components/dialog-move/dialog-collection-partial-move-to-existing-collection.tsx +++ b/src/views-components/dialog-move/dialog-collection-partial-move-to-existing-collection.tsx @@ -18,6 +18,7 @@ export const DialogCollectionPartialMoveToExistingCollection = (props: DialogCol dialogTitle='Move to existing collection' formFields={CollectionPartialMoveFields(props.pickerId)} submitLabel='Move files' + enableWhenPristine {...props} />; diff --git a/src/views-components/projects-tree-picker/projects-tree-picker.tsx b/src/views-components/projects-tree-picker/projects-tree-picker.tsx index 1f036829..773230d3 100644 --- a/src/views-components/projects-tree-picker/projects-tree-picker.tsx +++ b/src/views-components/projects-tree-picker/projects-tree-picker.tsx @@ -23,6 +23,7 @@ import { withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core'; import { ArvadosTheme } from 'common/custom-theme'; export interface ToplevelPickerProps { + currentUuid?: string; pickerId: string; includeCollections?: boolean; includeDirectories?: boolean; @@ -106,7 +107,7 @@ export const ProjectsTreePicker = connect(mapStateToProps, mapDispatchToProps)( componentDidMount() { const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(this.props.pickerId); - this.props.dispatch(initProjectsTreePicker(this.props.pickerId)); + this.props.dispatch(initProjectsTreePicker(this.props.pickerId, this.props.currentUuid)); this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_PROJECT_SEARCH({ pickerId: search, projectSearchValue: "" })); this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: search, collectionFilterValue: "" })); diff --git a/src/views-components/projects-tree-picker/tree-picker-field.tsx b/src/views-components/projects-tree-picker/tree-picker-field.tsx index d1ff0a0d..17417bf5 100644 --- a/src/views-components/projects-tree-picker/tree-picker-field.tsx +++ b/src/views-components/projects-tree-picker/tree-picker-field.tsx @@ -53,6 +53,7 @@ export const DirectoryTreePickerField = (props: WrappedFieldProps & PickerIdProp