20225: Add subdirectory selection support to directory input
[arvados-workbench2.git] / src / store / tree-picker / tree-picker-actions.ts
index 5e880aad9587583ffaeebde865b675612b752785..18385e31ef14da66aec69302574793e00c96c22e 100644 (file)
@@ -2,31 +2,36 @@
 //
 // 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 { createCollectionFilesTree } from "~/models/collection-file";
+import { unionize, ofType, UnionOf } from "common/unionize";
+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';
-import { getUserUuid } from "~/common/getuser";
-import { ServiceRepository } from '~/services/services';
-import { FilterBuilder } from '~/services/api/filter-builder';
+import { RootState } from 'store/store';
+import { getUserUuid } from "common/getuser";
+import { ServiceRepository } from 'services/services';
+import { FilterBuilder } from 'services/api/filter-builder';
 import { pipe, values } from 'lodash/fp';
-import { ResourceKind } from '~/models/resource';
-import { GroupContentsResource } from '~/services/groups-service/groups-service';
+import { ResourceKind } from 'models/resource';
+import { GroupContentsResource } from 'services/groups-service/groups-service';
 import { getTreePicker, TreePicker } from './tree-picker';
-import { ProjectsTreePickerItem } from '~/views-components/projects-tree-picker/generic-projects-tree-picker';
-import { OrderBuilder } from '~/services/api/order-builder';
-import { ProjectResource } from '~/models/project';
+import { ProjectsTreePickerItem } from './tree-picker-middleware';
+import { OrderBuilder } from 'services/api/order-builder';
+import { ProjectResource } from 'models/project';
 import { mapTree } from '../../models/tree';
-import { LinkResource, LinkClass } from "~/models/link";
-import { mapTreeValues } from "~/models/tree";
-import { sortFilesTree } from "~/services/collection-service/collection-service-files-response";
+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";
+import { getResource } from "store/resources/resources";
+import { updateResources } from "store/resources/resources-actions";
 
 export const treePickerActions = unionize({
     LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
     LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array<TreeNode<any>>, pickerId: string }>(),
     APPEND_TREE_PICKER_NODE_SUBTREE: ofType<{ id: string, subtree: Tree<any>, pickerId: string }>(),
     TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string, pickerId: string }>(),
+    EXPAND_TREE_PICKER_NODE: 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 }>(),
@@ -38,11 +43,29 @@ export const treePickerActions = unionize({
 
 export type TreePickerAction = UnionOf<typeof treePickerActions>;
 
+export interface LoadProjectParams {
+    includeCollections?: boolean;
+    includeDirectories?: boolean;
+    includeFiles?: boolean;
+    includeFilterGroups?: boolean;
+    options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; };
+}
+
+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>;
+
 export const getProjectsTreePickerIds = (pickerId: string) => ({
     home: `${pickerId}_home`,
     shared: `${pickerId}_shared`,
     favorites: `${pickerId}_favorites`,
-    publicFavorites: `${pickerId}_publicFavorites`
+    publicFavorites: `${pickerId}_publicFavorites`,
+    search: `${pickerId}_search`,
 });
 
 export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
@@ -68,13 +91,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) => {
-        const { home, shared, favorites, publicFavorites } = getProjectsTreePickerIds(pickerId);
+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> {
@@ -92,48 +120,108 @@ export const receiveTreePickerData = <T>(params: ReceiveTreePickerDataParams<T>)
             nodes: data.map(item => initTreeNode(extractNodeData(item))),
             pickerId,
         }));
-        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
+        dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
     };
 
-interface LoadProjectParams {
+interface LoadProjectParamsWithId extends LoadProjectParams {
     id: string;
     pickerId: string;
-    includeCollections?: boolean;
-    includeFiles?: boolean;
     loadShared?: boolean;
+    searchProjects?: boolean;
 }
-export const loadProject = (params: LoadProjectParams) =>
-    async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
-        const { id, pickerId, includeCollections = false, includeFiles = false, loadShared = false } = params;
+
+export const loadProject = (params: LoadProjectParamsWithId) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const {
+            id,
+            pickerId,
+            includeCollections = false,
+            includeDirectories = false,
+            includeFiles = false,
+            includeFilterGroups = false,
+            loadShared = false,
+            options,
+            searchProjects = false
+        } = params;
 
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
 
-        const filters = pipe(
-            (fb: FilterBuilder) => includeCollections
-                ? fb.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
-                : fb.addIsA('uuid', [ResourceKind.PROJECT]),
-            fb => fb.getFilters(),
-        )(new FilterBuilder());
+        let filterB = new FilterBuilder();
+
+        filterB = (includeCollections && !searchProjects)
+            ? filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
+            : filterB.addIsA('uuid', [ResourceKind.PROJECT]);
+
+        const state = getState();
+
+        if (state.treePickerSearch.collectionFilterValues[pickerId]) {
+            filterB = filterB.addFullTextSearch(state.treePickerSearch.collectionFilterValues[pickerId], 'collections');
+        } else {
+            filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
+        }
+
+        if (searchProjects && state.treePickerSearch.projectSearchValues[pickerId]) {
+            filterB = filterB.addFullTextSearch(state.treePickerSearch.projectSearchValues[pickerId], 'groups');
+        }
 
-        const { items } = await services.groupsService.contents(loadShared ? '' : id, { filters, excludeHomeProject: loadShared || undefined });
+        const filters = filterB.getFilters();
+
+        const itemLimit = 200;
+
+        const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit });
+        dispatch<any>(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: ""
+            });
+        }
 
         dispatch<any>(receiveTreePickerData<GroupContentsResource>({
             id,
             pickerId,
-            data: items,
-            extractNodeData: item => ({
-                id: item.uuid,
-                value: item,
-                status: item.kind === ResourceKind.PROJECT
-                    ? TreeNodeStatus.INITIAL
-                    : includeFiles
-                        ? TreeNodeStatus.INITIAL
-                        : TreeNodeStatus.LOADED
+            data: items.filter((item) => {
+                if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
+                    return false;
+                }
+
+                if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
+                    return false;
+                }
+
+                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
+                            : includeDirectories || includeFiles
+                                ? TreeNodeStatus.INITIAL
+                                : TreeNodeStatus.LOADED
+                    }),
         }));
     };
 
-export const loadCollection = (id: string, pickerId: string) =>
+export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
 
@@ -142,8 +230,11 @@ export const loadCollection = (id: string, pickerId: string) =>
 
             const node = getNode(id)(picker);
             if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
-
-                const files = await services.collectionService.files(node.value.portableDataHash);
+                const files = (await services.collectionService.files(node.value.uuid))
+                    .filter((file) => (
+                        (includeFiles) ||
+                        (includeDirectories && file.type === CollectionFileType.DIRECTORY)
+                    ));
                 const tree = createCollectionFilesTree(files);
                 const sorted = sortFilesTree(tree);
                 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
@@ -160,7 +251,7 @@ export const loadCollection = (id: string, pickerId: string) =>
         }
     };
 
-
+export const HOME_PROJECT_ID = 'Home Projects';
 export const initUserProject = (pickerId: string) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const uuid = getUserUuid(getState());
@@ -168,7 +259,7 @@ export const initUserProject = (pickerId: string) =>
             dispatch(receiveTreePickerData({
                 id: '',
                 pickerId,
-                data: [{ uuid, name: 'Projects' }],
+                data: [{ uuid, name: HOME_PROJECT_ID }],
                 extractNodeData: value => ({
                     id: value.uuid,
                     status: TreeNodeStatus.INITIAL,
@@ -177,11 +268,11 @@ export const initUserProject = (pickerId: string) =>
             }));
         }
     };
-export const loadUserProject = (pickerId: string, includeCollections = false, includeFiles = false) =>
+export const loadUserProject = (pickerId: string, includeCollections = false, includeDirectories = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const uuid = getUserUuid(getState());
         if (uuid) {
-            dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeFiles }));
+            dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
         }
     };
 
@@ -200,6 +291,35 @@ 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.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) => {
@@ -230,15 +350,34 @@ export const initPublicFavoritesProject = (pickerId: string) =>
         }));
     };
 
+export const SEARCH_PROJECT_ID = 'Search all Projects';
+export const initSearchProject = (pickerId: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(receiveTreePickerData({
+            id: '',
+            pickerId,
+            data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
+            extractNodeData: value => ({
+                id: value.uuid,
+                status: TreeNodeStatus.INITIAL,
+                value,
+            }),
+        }));
+    };
+
+
 interface LoadFavoritesProjectParams {
     pickerId: string;
     includeCollections?: boolean;
+    includeDirectories?: boolean;
     includeFiles?: boolean;
+    options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
 }
 
-export const loadFavoritesProject = (params: LoadFavoritesProjectParams) =>
+export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
+    options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        const { pickerId, includeCollections = false, includeFiles = false } = params;
+        const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
         const uuid = getUserUuid(getState());
         if (uuid) {
             const filters = pipe(
@@ -248,18 +387,28 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams) =>
                 fb => fb.getFilters(),
             )(new FilterBuilder());
 
-            const { items } = await services.favoriteService.list(uuid, { filters });
+            const { items } = await services.favoriteService.list(uuid, { filters }, options.showOnlyOwned);
 
             dispatch<any>(receiveTreePickerData<GroupContentsResource>({
                 id: 'Favorites',
                 pickerId,
-                data: items,
+                data: items.filter((item) => {
+                    if (options.showOnlyWritable && !(item as GroupResource).canWrite) {
+                        return false;
+                    }
+
+                    if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
+                        return false;
+                    }
+
+                    return true;
+                }),
                 extractNodeData: item => ({
                     id: item.uuid,
                     value: item,
                     status: item.kind === ResourceKind.PROJECT
                         ? TreeNodeStatus.INITIAL
-                        : includeFiles
+                        : includeDirectories || includeFiles
                             ? TreeNodeStatus.INITIAL
                             : TreeNodeStatus.LOADED
                 }),
@@ -269,38 +418,42 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams) =>
 
 export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        const { pickerId, includeCollections = false, includeFiles = false } = params;
+        const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
         const uuidPrefix = getState().auth.config.uuidPrefix;
-        const uuid = `${uuidPrefix}-j7d0g-fffffffffffffff`;
-        if (uuid) {
+        const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
 
-            const filters = pipe(
-                (fb: FilterBuilder) => includeCollections
-                    ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
-                    : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
-                fb => fb
-                    .addEqual('link_class', LinkClass.STAR)
-                    .addEqual('owner_uuid', uuid)
-                    .getFilters(),
-            )(new FilterBuilder());
+        const filters = pipe(
+            (fb: FilterBuilder) => includeCollections
+                ? fb.addIsA('head_uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
+                : fb.addIsA('head_uuid', [ResourceKind.PROJECT]),
+            fb => fb
+                .addEqual('link_class', LinkClass.STAR)
+                .addEqual('owner_uuid', publicProjectUuid)
+                .getFilters(),
+        )(new FilterBuilder());
 
-            const { items } = await services.linkService.list({ filters });
+        const { items } = await services.linkService.list({ filters });
 
-            dispatch<any>(receiveTreePickerData<LinkResource>({
-                id: 'Public Favorites',
-                pickerId,
-                data: items,
-                extractNodeData: item => ({
-                    id: item.headUuid,
-                    value: item,
-                    status: item.headKind === ResourceKind.PROJECT
+        dispatch<any>(receiveTreePickerData<LinkResource>({
+            id: 'Public Favorites',
+            pickerId,
+            data: items.filter(item => {
+                if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
+                    return false;
+                }
+
+                return true;
+            }),
+            extractNodeData: item => ({
+                id: item.headUuid,
+                value: item,
+                status: item.headKind === ResourceKind.PROJECT
+                    ? TreeNodeStatus.INITIAL
+                    : includeDirectories || includeFiles
                         ? TreeNodeStatus.INITIAL
-                        : includeFiles
-                            ? TreeNodeStatus.INITIAL
-                            : TreeNodeStatus.LOADED
-                }),
-            }));
-        }
+                        : TreeNodeStatus.LOADED
+            }),
+        }));
     };
 
 export const receiveTreePickerProjectsData = (id: string, projects: ProjectResource[], pickerId: string) =>
@@ -367,3 +520,59 @@ const buildParams = (ownerUuid: string) => {
             .getOrder()
     };
 };
+
+/**
+ * Given a tree picker item, return collection uuid and path
+ *   if the item represents a valid target/destination location
+ */
+export type FileOperationLocation = {
+    name: string;
+    uuid: string;
+    pdh?: string;
+    subpath: string;
+}
+export const getFileOperationLocation = (item: ProjectsTreePickerItem) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<FileOperationLocation | undefined> => {
+        if ('kind' in item && item.kind === ResourceKind.COLLECTION) {
+            return {
+                name: item.name,
+                uuid: item.uuid,
+                pdh: item.portableDataHash,
+                subpath: '/',
+            };
+        } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
+            const uuid = getCollectionResourceCollectionUuid(item.id);
+            if (uuid) {
+                const collection = getResource<CollectionResource>(uuid)(getState().resources);
+                if (collection) {
+                    const itemPath = [item.path, item.name].join('/');
+
+                    return {
+                        name: item.name,
+                        uuid,
+                        pdh: collection.portableDataHash,
+                        subpath: itemPath,
+                    };
+                }
+            }
+        }
+        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: false,
+            status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
+        })(tree), createTree<GroupResource | CollectionResource>());
+};