Merge branch '21764-project-picker-crash' into main. Closes #21764
authorStephen Smith <stephen@curii.com>
Mon, 1 Jul 2024 16:09:09 +0000 (12:09 -0400)
committerStephen Smith <stephen@curii.com>
Mon, 1 Jul 2024 16:10:37 +0000 (12:10 -0400)
Arvados-DCO-1.1-Signed-off-by: Stephen Smith <stephen@curii.com>

services/workbench2/docker/Dockerfile
services/workbench2/src/store/processes/process.ts
services/workbench2/src/store/tree-picker/tree-picker-actions.ts
services/workbench2/src/views-components/tree-picker/tree-picker.test.tsx [new file with mode: 0644]
services/workbench2/src/views-components/tree-picker/tree-picker.ts
services/workbench2/tools/run-integration-tests.sh

index fa4266191aa0c3f8998cc92f879ba4195b6aef4a..be8b9d9c632729e39c8c0d9a928f9bf7dc8a34de 100644 (file)
@@ -33,7 +33,12 @@ RUN cd /usr/src/arvados && \
 RUN cd /usr/src/arvados && \
     apt-get update && \
     go mod download && \
-    go run ./cmd/arvados-server install -type test && cd .. && \
+    go run ./cmd/arvados-server install -type test && \
+    # Installing WB2 deps persists cypress to the home folder
+    # This is convenient to running cypress locally where yarn is run outside the container
+    cd services/workbench2 && \
+    yarn install && \
+    cd /usr/src/arvados && \
     rm -rf arvados && \
     apt-get clean
 
index a31fd9eac8b12b9cf8eea5d3956d588d47f8cc79..481fcb863cec07e76990e01e16d3691b52c04495 100644 (file)
@@ -30,6 +30,11 @@ export enum ProcessStatus {
     CANCELLING = 'Cancelling',
 }
 
+/**
+ * Gets a process from the store using container request uuid
+ * @param uuid container request associated with process
+ * @returns a Process object with containerRequest and optional container or undefined
+ */
 export const getProcess = (uuid: string) => (resources: ResourcesState): Process | undefined => {
     if (extractUuidKind(uuid) === ResourceKind.CONTAINER_REQUEST) {
         const containerRequest = getResource<ContainerRequestResource>(uuid)(resources);
index 883847d85464e7f374504118c50bba935872de4f..4dc995338b655660f2540d1eb26e8bf0dbb04854 100644 (file)
@@ -138,6 +138,23 @@ export const receiveTreePickerData = <T>(params: ReceiveTreePickerDataParams<T>)
         dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
     };
 
+export const extractGroupContentsNodeData = (expandableCollections: boolean) => (item: GroupContentsResource) => (
+    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
+                : item.kind === ResourceKind.COLLECTION && expandableCollections
+                    ? TreeNodeStatus.INITIAL
+                    : TreeNodeStatus.LOADED
+        }
+);
 interface LoadProjectParamsWithId extends LoadProjectParams {
     id: string;
     pickerId: string;
@@ -222,22 +239,7 @@ export const loadProject = (params: LoadProjectParamsWithId) =>
 
                     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
-                        }),
+                extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
             }));
         } catch(e) {
             console.error("Failed to load project into tree picker:", e);;
@@ -528,15 +530,7 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
 
                     return true;
                 }),
-                extractNodeData: item => ({
-                    id: item.uuid,
-                    value: item,
-                    status: item.kind === ResourceKind.PROJECT
-                        ? TreeNodeStatus.INITIAL
-                        : includeDirectories || includeFiles
-                            ? TreeNodeStatus.INITIAL
-                            : TreeNodeStatus.LOADED
-                }),
+                extractNodeData: extractGroupContentsNodeData(includeDirectories || includeFiles),
             }));
         }
     };
diff --git a/services/workbench2/src/views-components/tree-picker/tree-picker.test.tsx b/services/workbench2/src/views-components/tree-picker/tree-picker.test.tsx
new file mode 100644 (file)
index 0000000..1d37123
--- /dev/null
@@ -0,0 +1,151 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import Axios from "axios";
+import Adapter from "enzyme-adapter-react-16";
+import { configure, mount } from "enzyme";
+import { mockConfig } from "common/config";
+import { ServiceRepository, createServices } from "services/services";
+import { createBrowserHistory } from "history";
+import { ApiActions } from "services/api/api-actions";
+import { Provider } from "react-redux";
+import { configureStore } from "store/store";
+import { TreePicker } from "./tree-picker";
+import { initUserProject, receiveTreePickerData, extractGroupContentsNodeData } from "store/tree-picker/tree-picker-actions";
+import { authActions } from "store/auth/auth-action";
+import { ResourceKind } from "models/resource";
+import { updateResources } from "store/resources/resources-actions";
+
+configure({ adapter: new Adapter() });
+
+describe('<TreePicker />', () => {
+    let store;
+    let services: ServiceRepository;
+    const axiosInst = Axios.create({ headers: {} });
+    const config: any = {};
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => { },
+        errorFn: (id: string, message: string) => { }
+    };
+    const TEST_PICKER_ID = 'testPickerId';
+    const fakeUser = {
+        email: "test@test.com",
+        firstName: "John",
+        lastName: "Doe",
+        uuid: "zzzzz-tpzed-xurymjxw79nv3jz",
+        ownerUuid: "ownerUuid",
+        username: "username",
+        prefs: {},
+        isAdmin: false,
+        isActive: true,
+        canWrite: false,
+        canManage: false,
+    };
+    const renderItem = (item) => (
+        <li data-id={item.id}>{item.data.name}</li>
+    );
+
+    beforeEach(() => {
+        services = createServices(mockConfig({}), actions, axiosInst);
+        store = configureStore(createBrowserHistory(), services, config);
+        store.dispatch(authActions.USER_DETAILS_SUCCESS(fakeUser));
+        store.dispatch(initUserProject(TEST_PICKER_ID));
+    });
+
+    it("renders tree picker with initial home project state", () => {
+        let treePicker = mount(
+            <Provider store={store}>
+                <TreePicker
+                    pickerId={TEST_PICKER_ID}
+                    render={renderItem}
+                    onContextMenu={() => {}}
+                    toggleItemOpen={() => {}}
+                    toggleItemActive={() => {}}
+                    toggleItemSelection={() => {}}
+                />
+            </Provider>);
+
+        expect(treePicker.find(`li[data-id="${fakeUser.uuid}"]`).text()).toBe('Home Projects');
+    });
+
+    it("displays item loaded into treePicker store", () => {
+        const fakeProject = {
+            uuid: "zzzzz-j7d0g-111111111111111",
+            name: "FakeProject",
+            kind: ResourceKind.PROJECT,
+        };
+
+        store.dispatch(receiveTreePickerData({
+            id: fakeUser.uuid,
+            pickerId: TEST_PICKER_ID,
+            data: [fakeProject],
+            extractNodeData: extractGroupContentsNodeData(false)
+        }));
+
+        let treePicker = mount(
+            <Provider store={store}>
+                <TreePicker
+                    pickerId={TEST_PICKER_ID}
+                    render={renderItem}
+                    onContextMenu={() => {}}
+                    toggleItemOpen={() => {}}
+                    toggleItemActive={() => {}}
+                    toggleItemSelection={() => {}}
+                />
+            </Provider>);
+
+        expect(treePicker.find(`[data-id="${fakeUser.uuid}"]`).text()).toBe('Home Projects');
+        expect(treePicker.find(`[data-id="${fakeProject.uuid}"]`).text()).toBe('FakeProject');
+    });
+
+    it("preserves treenode name when exists in resources", () => {
+        const treeProjectResource = {
+            uuid: "zzzzz-j7d0g-111111111111111",
+            name: "FakeProject",
+            kind: ResourceKind.PROJECT,
+        };
+        const treeProjectResource2 = {
+            uuid: "zzzzz-j7d0g-222222222222222",
+            name: "",
+            kind: ResourceKind.PROJECT,
+        };
+
+        const storeProjectResource = {
+            ...treeProjectResource,
+            name: "StoreProjectName",
+            description: "Test description",
+        };
+        const storeProjectResource2 = {
+            ...treeProjectResource2,
+            name: "StoreProjectName2",
+            description: "Test description",
+        };
+
+        store.dispatch(updateResources([storeProjectResource, storeProjectResource2]));
+        store.dispatch(receiveTreePickerData({
+            id: fakeUser.uuid,
+            pickerId: TEST_PICKER_ID,
+            data: [treeProjectResource, treeProjectResource2],
+            extractNodeData: extractGroupContentsNodeData(false)
+        }));
+
+        let treePicker = mount(
+            <Provider store={store}>
+                <TreePicker
+                    pickerId={TEST_PICKER_ID}
+                    render={renderItem}
+                    onContextMenu={() => {}}
+                    toggleItemOpen={() => {}}
+                    toggleItemActive={() => {}}
+                    toggleItemSelection={() => {}}
+                />
+            </Provider>);
+
+        expect(treePicker.find(`[data-id="${fakeUser.uuid}"]`).text()).toBe('Home Projects');
+        expect(treePicker.find(`[data-id="${treeProjectResource.uuid}"]`).text()).toBe('FakeProject');
+        expect(treePicker.find(`[data-id="${treeProjectResource2.uuid}"]`).text()).toBe('');
+    });
+
+});
index 1b6d2a26fd0dff5dbbeb2d7442c83c9af945419a..6ea8c56b184ad93edfdf86401ee778b9d2e19f93 100644 (file)
@@ -3,12 +3,13 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { connect } from "react-redux";
-import { Tree, TreeProps, TreeItem, TreeItemStatus } from "components/tree/tree";
+import { Tree as TreeComponent, TreeProps, TreeItem, TreeItemStatus } from "components/tree/tree";
 import { RootState } from "store/store";
-import { getNodeChildrenIds, Tree as Ttree, createTree, getNode, TreeNodeStatus } from 'models/tree';
+import { getNodeChildrenIds, Tree, createTree, getNode, TreeNodeStatus } from 'models/tree';
 import { Dispatch } from "redux";
 import { initTreeNode } from '../../models/tree';
 import { ResourcesState } from "store/resources/resources";
+import { Resource } from "models/resource";
 
 type Callback<T> = (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>, pickerId: string) => void;
 export interface TreePickerProps<T> {
@@ -19,18 +20,18 @@ export interface TreePickerProps<T> {
     toggleItemSelection: Callback<T>;
 }
 
-const flatTree = (itemsIdMap: Map<string, any>, depth: number, items?: any): [] => {
+const flatTree = <T>(itemsIdMap: Map<string, TreeItem<T>>, depth: number, items?: TreeItem<T>[]): TreeItem<T>[] => {
     return items ? items
-        .map((item: any) => addToItemsIdMap(item, itemsIdMap))
-        .reduce((prev: Array<any>, next: any) => {
+        .map((item: TreeItem<T>) => addToItemsIdMap(item, itemsIdMap))
+        .reduce((acc: Array<TreeItem<T>>, next: TreeItem<T>) => {
             const { items } = next;
-            prev.push({ ...next, depth });
-            prev.push(...(next.open ? flatTree(itemsIdMap, depth + 1, items) : []));
-            return prev;
-        }, []) : [];
+            acc.push({ ...next, depth });
+            acc.push(...(next.open ? flatTree(itemsIdMap, depth + 1, items) : []));
+            return acc;
+        }, [] as TreeItem<T>[]) : [];
 };
 
-const addToItemsIdMap = <T>(item: TreeItem<T>, itemsIdMap: Map<string, TreeItem<T>>) => {
+const addToItemsIdMap = <T>(item: TreeItem<T>, itemsIdMap: Map<string, TreeItem<T>>): TreeItem<T> => {
     itemsIdMap[item.id] = item;
     return item;
 };
@@ -38,7 +39,7 @@ const addToItemsIdMap = <T>(item: TreeItem<T>, itemsIdMap: Map<string, TreeItem<
 const mapStateToProps =
     <T>(state: RootState, props: TreePickerProps<T>): Pick<TreeProps<T>, 'items' | 'disableRipple' | 'itemsMap'> => {
         const itemsIdMap: Map<string, TreeItem<T>> = new Map();
-        const tree = state.treePicker[props.pickerId] || createTree();
+        const tree: Tree<T> = state.treePicker[props.pickerId] || createTree<T>();
         return {
             disableRipple: true,
             items: getNodeChildrenIds('')(tree)
@@ -53,24 +54,34 @@ const mapStateToProps =
         };
     };
 
-const mapDispatchToProps = (_: Dispatch, props: TreePickerProps<any>): Pick<TreeProps<any>, 'onContextMenu' | 'toggleItemOpen' | 'toggleItemActive' | 'toggleItemSelection'> => ({
+const mapDispatchToProps = <T>(_: Dispatch, props: TreePickerProps<T>): Pick<TreeProps<T>, 'onContextMenu' | 'toggleItemOpen' | 'toggleItemActive' | 'toggleItemSelection'> => ({
     onContextMenu: (event, item) => props.onContextMenu(event, item, props.pickerId),
     toggleItemActive: (event, item) => props.toggleItemActive(event, item, props.pickerId),
     toggleItemOpen: (event, item) => props.toggleItemOpen(event, item, props.pickerId),
     toggleItemSelection: (event, item) => props.toggleItemSelection(event, item, props.pickerId),
 });
 
-export const TreePicker = connect(mapStateToProps, mapDispatchToProps)(Tree);
+export const TreePicker = connect(mapStateToProps, mapDispatchToProps)(TreeComponent);
 
-const treePickerToTreeItems = (tree: Ttree<any>, resources: ResourcesState) =>
+const treePickerToTreeItems = <T>(tree: Tree<T>, resources: ResourcesState) =>
     (id: string): TreeItem<any> => {
         const node = getNode(id)(tree) || initTreeNode({ id: '', value: 'InvalidNode' });
         const items = getNodeChildrenIds(node.id)(tree)
             .map(treePickerToTreeItems(tree, resources));
-        const resource = resources[node.id];
+        const resource = resources[node.id] as (Resource | undefined);
+
         return {
             active: node.active,
-            data: resource ? { ...resource, name: node.value.name || node.value } : node.value,
+            data: resource
+                ? {
+                    ...resource,
+                    name: typeof node.value === "string"
+                        ? node.value
+                        : typeof (node.value as any).name === "string"
+                            ? (node.value as any).name
+                            : ""
+                  }
+                : node.value,
             id: node.id,
             items: items.length > 0 ? items : undefined,
             open: node.expanded,
@@ -89,4 +100,3 @@ export const treeNodeStatusToTreeItem = (status: TreeNodeStatus) => {
             return TreeItemStatus.LOADED;
     }
 };
-
index b2745b3a6e1ca90cace7b5b797d05e8e4e4cd61b..2c3717f00db7e1eb5582c1d8601adc0460df9147 100755 (executable)
@@ -41,7 +41,7 @@ while getopts "ia:w:" o; do
     case "${o}" in
         i)
             # Interactive mode
-            CYPRESS_MODE="open"
+            CYPRESS_MODE="open --e2e"
             ;;
         a)
             ARVADOS_DIR=${OPTARG}