From: Stephen Smith Date: Mon, 1 Jul 2024 16:09:09 +0000 (-0400) Subject: Merge branch '21764-project-picker-crash' into main. Closes #21764 X-Git-Url: https://git.arvados.org/arvados.git/commitdiff_plain/114b890d58dbe8f7a4c41aeb14f7042c9145199e?hp=be6a3e646c705c8dc6db337114085cc6078df09f Merge branch '21764-project-picker-crash' into main. Closes #21764 Arvados-DCO-1.1-Signed-off-by: Stephen Smith --- diff --git a/services/workbench2/docker/Dockerfile b/services/workbench2/docker/Dockerfile index fa4266191a..be8b9d9c63 100644 --- a/services/workbench2/docker/Dockerfile +++ b/services/workbench2/docker/Dockerfile @@ -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 diff --git a/services/workbench2/src/store/processes/process.ts b/services/workbench2/src/store/processes/process.ts index a31fd9eac8..481fcb863c 100644 --- a/services/workbench2/src/store/processes/process.ts +++ b/services/workbench2/src/store/processes/process.ts @@ -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(uuid)(resources); 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 883847d854..4dc995338b 100644 --- a/services/workbench2/src/store/tree-picker/tree-picker-actions.ts +++ b/services/workbench2/src/store/tree-picker/tree-picker-actions.ts @@ -138,6 +138,23 @@ export const receiveTreePickerData = (params: ReceiveTreePickerDataParams) 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 index 0000000000..1d37123eb2 --- /dev/null +++ b/services/workbench2/src/views-components/tree-picker/tree-picker.test.tsx @@ -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('', () => { + 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) => ( +
  • {item.data.name}
  • + ); + + 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( + + {}} + toggleItemOpen={() => {}} + toggleItemActive={() => {}} + toggleItemSelection={() => {}} + /> + ); + + 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( + + {}} + toggleItemOpen={() => {}} + toggleItemActive={() => {}} + toggleItemSelection={() => {}} + /> + ); + + 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( + + {}} + toggleItemOpen={() => {}} + toggleItemActive={() => {}} + toggleItemSelection={() => {}} + /> + ); + + 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(''); + }); + +}); diff --git a/services/workbench2/src/views-components/tree-picker/tree-picker.ts b/services/workbench2/src/views-components/tree-picker/tree-picker.ts index 1b6d2a26fd..6ea8c56b18 100644 --- a/services/workbench2/src/views-components/tree-picker/tree-picker.ts +++ b/services/workbench2/src/views-components/tree-picker/tree-picker.ts @@ -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 = (event: React.MouseEvent, item: TreeItem, pickerId: string) => void; export interface TreePickerProps { @@ -19,18 +20,18 @@ export interface TreePickerProps { toggleItemSelection: Callback; } -const flatTree = (itemsIdMap: Map, depth: number, items?: any): [] => { +const flatTree = (itemsIdMap: Map>, depth: number, items?: TreeItem[]): TreeItem[] => { return items ? items - .map((item: any) => addToItemsIdMap(item, itemsIdMap)) - .reduce((prev: Array, next: any) => { + .map((item: TreeItem) => addToItemsIdMap(item, itemsIdMap)) + .reduce((acc: Array>, next: TreeItem) => { 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[]) : []; }; -const addToItemsIdMap = (item: TreeItem, itemsIdMap: Map>) => { +const addToItemsIdMap = (item: TreeItem, itemsIdMap: Map>): TreeItem => { itemsIdMap[item.id] = item; return item; }; @@ -38,7 +39,7 @@ const addToItemsIdMap = (item: TreeItem, itemsIdMap: Map(state: RootState, props: TreePickerProps): Pick, 'items' | 'disableRipple' | 'itemsMap'> => { const itemsIdMap: Map> = new Map(); - const tree = state.treePicker[props.pickerId] || createTree(); + const tree: Tree = state.treePicker[props.pickerId] || createTree(); return { disableRipple: true, items: getNodeChildrenIds('')(tree) @@ -53,24 +54,34 @@ const mapStateToProps = }; }; -const mapDispatchToProps = (_: Dispatch, props: TreePickerProps): Pick, 'onContextMenu' | 'toggleItemOpen' | 'toggleItemActive' | 'toggleItemSelection'> => ({ +const mapDispatchToProps = (_: Dispatch, props: TreePickerProps): Pick, '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, resources: ResourcesState) => +const treePickerToTreeItems = (tree: Tree, resources: ResourcesState) => (id: string): TreeItem => { 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; } }; - diff --git a/services/workbench2/tools/run-integration-tests.sh b/services/workbench2/tools/run-integration-tests.sh index b2745b3a6e..2c3717f00d 100755 --- a/services/workbench2/tools/run-integration-tests.sh +++ b/services/workbench2/tools/run-integration-tests.sh @@ -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}