20031: Preselect current collection in move/copy to existing collection tree picker
authorStephen Smith <stephen@curii.com>
Thu, 27 Apr 2023 14:36:19 +0000 (10:36 -0400)
committerStephen Smith <stephen@curii.com>
Thu, 27 Apr 2023 14:36:19 +0000 (10:36 -0400)
* 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 <stephen@curii.com>

src/services/ancestors-service/ancestors-service.ts
src/services/services.ts
src/store/collections/collection-partial-copy-actions.ts
src/store/collections/collection-partial-move-actions.ts
src/store/tree-picker/tree-picker-actions.ts
src/store/tree-picker/tree-picker-middleware.ts
src/views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection.tsx
src/views-components/dialog-move/dialog-collection-partial-move-to-existing-collection.tsx
src/views-components/projects-tree-picker/projects-tree-picker.tsx
src/views-components/projects-tree-picker/tree-picker-field.tsx

index 90a0bf840873533f5ff9e2a803322caad9fb536b..188c233e746b2d48489614baded270e388014121 100644 (file)
@@ -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<Array<UserResource | GroupResource>> {
+    async ancestors(startUuid: string, endUuid: string): Promise<Array<UserResource | GroupResource | CollectionResource>> {
         return this._ancestors(startUuid, endUuid);
     }
 
-    private async _ancestors(startUuid: string, endUuid: string, previousUuid = ''): Promise<Array<UserResource | GroupResource>> {
+    private async _ancestors(startUuid: string, endUuid: string, previousUuid = ''): Promise<Array<UserResource | GroupResource | CollectionResource>> {
 
         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;
         }
index 4e4a682ebe065ed7247695c1ba760df5d0833f06..be6f16b60611337e482a9ba162c5abbe2be7afe6 100644 (file)
@@ -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();
index f9ecb5cafa5487cd43070573f1465ff60b3b0d6c..5da7c8f1d66d2525dc3ff96257e5c1024edd6958 100644 (file)
@@ -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<any>(resetPickerProjectTree());
index 44e6a9bfe2a94720b4ecfc6db81409bac2e21993..8b4492efa4c6313a8e807d07951c764dff1fabbb 100644 (file)
@@ -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<any>(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));
index 505e0622e757e00f72b53f0d7a905d0d0e1b9394..bf40394c8c1e77ee44fd46a1ee28043f2bdd3814 100644 (file)
@@ -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<typeof treePickerSearchActions>;
@@ -87,14 +89,18 @@ export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Va
 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
     getAllNodes<Value>(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<any>(initUserProject(home));
         dispatch<any>(initSharedProject(shared));
         dispatch<any>(initFavoritesProject(favorites));
         dispatch<any>(initPublicFavoritesProject(publicFavorites));
         dispatch<any>(initSearchProject(search));
+
+        if (selectedItemUuid) {
+            dispatch<any>(loadInitialValue(selectedItemUuid, pickerId));
+        }
     };
 
 interface ReceiveTreePickerDataParams<T> {
@@ -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<any>, 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<any>, 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<any>, 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<GroupResource | CollectionResource>, 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<GroupResource | CollectionResource>());
+};
index 8fa3ee4a162c0de98ac8a70e34eb251ae4d452d4..6f748a99b47cf2ab431973237d73f56fbcfbcab0 100644 (file)
@@ -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<any>((dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-                const picker = getTreePicker<ProjectsTreePickerItem>(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<any>(loadProject({
-                                        ...loadParams,
-                                        id: node.id,
-                                        pickerId: pickerId,
-                                    }));
-                                }
-                                if (node.id === SHARED_PROJECT_ID) {
-                                    dispatch<any>(loadProject({
-                                        ...loadParams,
-                                        id: node.id,
-                                        pickerId: pickerId,
-                                        loadShared: true
-                                    }));
-                                }
-                                if (node.id === SEARCH_PROJECT_ID) {
-                                    dispatch<any>(loadProject({
-                                        ...loadParams,
-                                        id: node.id,
-                                        pickerId: pickerId,
-                                        searchProjects: true
-                                    }));
-                                }
-                                if (node.id === FAVORITES_PROJECT_ID) {
-                                    dispatch<any>(loadFavoritesProject({
-                                        ...loadParams,
-                                        pickerId: pickerId,
-                                    }));
-                                }
-                                if (node.id === PUBLIC_FAVORITES_PROJECT_ID) {
-                                    dispatch<any>(loadPublicFavoritesProject({
-                                        ...loadParams,
-                                        pickerId: pickerId,
-                                    }));
-                                }
-                            }
-                            return id;
-                        });
-                }
-            }),
+        SET_TREE_PICKER_COLLECTION_FILTER: refreshPickers(store),
         default: () => { }
     });
 
     return r;
 }
+
+const refreshPickers = (store: MiddlewareAPI) => ({ pickerId }) =>
+    store.dispatch<any>((dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const picker = getTreePicker<ProjectsTreePickerItem>(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<any>(loadProject({
+                                ...loadParams,
+                                id: node.id,
+                                pickerId: pickerId,
+                            }));
+                        }
+                        if (node.id === SHARED_PROJECT_ID) {
+                            dispatch<any>(loadProject({
+                                ...loadParams,
+                                id: node.id,
+                                pickerId: pickerId,
+                                loadShared: true
+                            }));
+                        }
+                        if (node.id === SEARCH_PROJECT_ID) {
+                            dispatch<any>(loadProject({
+                                ...loadParams,
+                                id: node.id,
+                                pickerId: pickerId,
+                                searchProjects: true
+                            }));
+                        }
+                        if (node.id === FAVORITES_PROJECT_ID) {
+                            dispatch<any>(loadFavoritesProject({
+                                ...loadParams,
+                                pickerId: pickerId,
+                            }));
+                        }
+                        if (node.id === PUBLIC_FAVORITES_PROJECT_ID) {
+                            dispatch<any>(loadPublicFavoritesProject({
+                                ...loadParams,
+                                pickerId: pickerId,
+                            }));
+                        }
+                    }
+                    return id;
+                });
+        }
+    })
index f6d4db21b20c820b7007d26514d57e5884ee409e..eb95d1f2b6970a7a4b156199acf565e95159ab4d 100644 (file)
@@ -18,6 +18,7 @@ export const DialogCollectionPartialCopyToExistingCollection = (props: DialogCol
         dialogTitle='Copy to existing collection'
         formFields={CollectionPartialCopyFields(props.pickerId)}
         submitLabel='Copy files'
+        enableWhenPristine
         {...props}
     />;
 
index f95bd24fb31ddbc7214b2d57df95e2ca3e71309b..5cd4996df9c3aae95c4270f609f3f603b18764e9 100644 (file)
@@ -18,6 +18,7 @@ export const DialogCollectionPartialMoveToExistingCollection = (props: DialogCol
         dialogTitle='Move to existing collection'
         formFields={CollectionPartialMoveFields(props.pickerId)}
         submitLabel='Move files'
+        enableWhenPristine
         {...props}
     />;
 
index 1f03682906754c18a411398c20dc90cba6d8a599..773230d351fe1e491faa5f4dab49a81f375aff84 100644 (file)
@@ -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<any>(initProjectsTreePicker(this.props.pickerId));
+                this.props.dispatch<any>(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: "" }));
index d1ff0a0dbc45339dbb9418a2b3ca5b6cfe8011a0..17417bf554f7bafcaf92c2fa1afbc03fcd3f6a2d 100644 (file)
@@ -53,6 +53,7 @@ export const DirectoryTreePickerField = (props: WrappedFieldProps & PickerIdProp
     <div style={{ display: 'flex', minHeight: 0, flexDirection: 'column' }}>
         <div style={{ flexBasis: '275px', flexShrink: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
             <ProjectsTreePicker
+                currentUuid={props.input.value.uuid}
                 pickerId={props.pickerId}
                 toggleItemActive={handleDirectoryChange(props)}
                 options={{ showOnlyOwned: false, showOnlyWritable: true }}