From a0db43c4a4bb65d2e1dcae6a6da84eec0655a0cc Mon Sep 17 00:00:00 2001 From: "Elay Aharoni (EXT-Nokia)" Date: Thu, 5 Dec 2024 15:33:32 +0200 Subject: [PATCH 1/2] Filter workspaces table Signed-off-by: Elay Aharoni (EXT-Nokia) --- .../src/app/Generic components/Filter.tsx | 166 ++++++++++ .../src/app/pages/Workspaces/Workspaces.tsx | 306 +++++++----------- 2 files changed, 289 insertions(+), 183 deletions(-) create mode 100644 workspaces/frontend/src/app/Generic components/Filter.tsx diff --git a/workspaces/frontend/src/app/Generic components/Filter.tsx b/workspaces/frontend/src/app/Generic components/Filter.tsx new file mode 100644 index 00000000..d287dd60 --- /dev/null +++ b/workspaces/frontend/src/app/Generic components/Filter.tsx @@ -0,0 +1,166 @@ +import * as React from 'react'; +import { + Button, + Menu, + MenuContent, + MenuItem, + MenuList, + MenuToggle, + MenuToggleElement, + Popper, + SearchInput, + Toolbar, + ToolbarContent, + ToolbarFilter, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons'; + +interface FilterProps { + onFilter: (activeAttributeMenu: string, value: string) => void; + columnNames: { [key: string]: string }; +} + +const Filter: React.FC = ({ onFilter, columnNames }) => { + const [activeFilter, setActiveFilter] = React.useState(Object.values(columnNames)[0]); + const [searchValue, setSearchValue] = React.useState(''); + const [isFilterMenuOpen, setIsFilterMenuOpen] = React.useState(false); + + const filterToggleRef = React.useRef(null); + const filterMenuRef = React.useRef(null); + const filterContainerRef = React.useRef(null); + + const handlefilterMenuKeys = React.useCallback( + (event: KeyboardEvent) => { + if (!isFilterMenuOpen) { + return; + } + if ( + filterMenuRef.current?.contains(event.target as Node) || + filterToggleRef.current?.contains(event.target as Node) + ) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsFilterMenuOpen(!isFilterMenuOpen); + filterToggleRef.current?.focus(); + } + } + }, + [isFilterMenuOpen, filterMenuRef, filterToggleRef], + ); + + const handleClickOutside = React.useCallback( + (event: MouseEvent) => { + if (isFilterMenuOpen && !filterMenuRef.current?.contains(event.target as Node)) { + setIsFilterMenuOpen(false); + } + }, + [isFilterMenuOpen, filterMenuRef], + ); + + React.useEffect(() => { + window.addEventListener('keydown', handlefilterMenuKeys); + window.addEventListener('click', handleClickOutside); + return () => { + window.removeEventListener('keydown', handlefilterMenuKeys); + window.removeEventListener('click', handleClickOutside); + }; + }, [isFilterMenuOpen, filterMenuRef, handlefilterMenuKeys, handleClickOutside]); + + const onFilterToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); // Stop handleClickOutside from handling + setTimeout(() => { + if (filterMenuRef.current) { + const firstElement = filterMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsFilterMenuOpen(!isFilterMenuOpen); + }; + + const FilterMenuToggle = ( + } + > + {activeFilter} + + ); + + const filterMenu = ( + { + setActiveFilter(itemId); + setIsFilterMenuOpen(!isFilterMenuOpen); + }} + > + + + {Object.values(columnNames).map((name: string) => ( + + {name} + + ))} + + + + ); + + const filterDropdown = ( +
+ +
+ ); + + const onSearchChange = (value: string) => { + setSearchValue(value); + onFilter(activeFilter, value); + }; + + return ( + { + onSearchChange(''); + }} + > + + } breakpoint="xl"> + + {filterDropdown} + setSearchValue('')} + deleteLabelGroup={() => setSearchValue('')} + categoryName={activeFilter} + > + onSearchChange(value)} + onClear={() => onSearchChange('')} + /> + + + + + + + ); +}; +export default Filter; diff --git a/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx b/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx index c83094d8..3b50959b 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx @@ -1,25 +1,10 @@ import * as React from 'react'; import { PageSection, - MenuToggle, TimestampTooltipVariant, Timestamp, Label, Title, - Popper, - MenuToggleElement, - Menu, - MenuContent, - MenuList, - MenuItem, - Toolbar, - ToolbarContent, - ToolbarToggleGroup, - ToolbarGroup, - ToolbarItem, - ToolbarFilter, - SearchInput, - Button, PaginationVariant, Pagination, } from '@patternfly/react-core'; @@ -34,10 +19,94 @@ import { ActionsColumn, IActions, } from '@patternfly/react-table'; +import { useState } from 'react'; +import { Workspace, WorkspaceState } from '~/shared/types'; +import Filter from '~/app/Generic components/Filter'; import { FilterIcon } from '@patternfly/react-icons'; import { Workspace, WorkspaceState } from 'shared/types'; import { formatRam } from 'shared/utilities/WorkspaceResources'; +/* Mocked workspaces, to be removed after fetching info from backend */ +const mockWorkspaces: Workspace[] = [ + { + name: 'My Jupyter Notebook', + namespace: 'namespace1', + paused: true, + deferUpdates: true, + kind: 'jupyter-lab', + podTemplate: { + volumes: { + home: '/home', + data: [ + { + pvcName: 'data', + mountPath: '/data', + readOnly: false, + }, + ], + }, + }, + options: { + imageConfig: 'jupyterlab_scipy_180', + podConfig: 'Small CPU', + }, + status: { + activity: { + lastActivity: 0, + lastUpdate: 0, + }, + pauseTime: 0, + pendingRestart: false, + podTemplateOptions: { + imageConfig: { + desired: '', + redirectChain: [], + }, + }, + state: WorkspaceState.Paused, + stateMessage: 'It is paused.', + }, + }, + { + name: 'My Other Jupyter Notebook', + namespace: 'namespace1', + paused: false, + deferUpdates: false, + kind: 'jupyter-lab', + podTemplate: { + volumes: { + home: '/home', + data: [ + { + pvcName: 'data', + mountPath: '/data', + readOnly: false, + }, + ], + }, + }, + options: { + imageConfig: 'jupyterlab_scipy_180', + podConfig: 'Large CPU', + }, + status: { + activity: { + lastActivity: 0, + lastUpdate: 0, + }, + pauseTime: 0, + pendingRestart: false, + podTemplateOptions: { + imageConfig: { + desired: '', + redirectChain: [], + }, + }, + state: WorkspaceState.Running, + stateMessage: 'It is running.', + }, + }, +]; export const Workspaces: React.FunctionComponent = () => { /* Mocked workspaces, to be removed after fetching info from backend */ const workspaces: Workspace[] = [ @@ -125,6 +194,17 @@ export const Workspaces: React.FunctionComponent = () => { }, ]; +// Table columns +const columnNames = { + name: 'Name', + kind: 'Kind', + image: 'Image', + podConfig: 'Pod Config', + state: 'State', + homeVol: 'Home Vol', + dataVol: 'Data Vol', + lastActivity: 'Last Activity', +}; // Table columns const columnNames = { name: 'Name', @@ -139,153 +219,13 @@ export const Workspaces: React.FunctionComponent = () => { lastActivity: 'Last Activity', }; - // Filter - const [activeAttributeMenu, setActiveAttributeMenu] = React.useState(columnNames.name); - const [isAttributeMenuOpen, setIsAttributeMenuOpen] = React.useState(false); - const attributeToggleRef = React.useRef(null); - const attributeMenuRef = React.useRef(null); - const attributeContainerRef = React.useRef(null); - - const [searchValue, setSearchValue] = React.useState(''); - - const searchInput = ( - onSearchChange(value)} - onClear={() => onSearchChange('')} - /> - ); - - const handleAttributeMenuKeys = React.useCallback( - (event: KeyboardEvent) => { - if (!isAttributeMenuOpen) { - return; - } - if ( - attributeMenuRef.current?.contains(event.target as Node) || - attributeToggleRef.current?.contains(event.target as Node) - ) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsAttributeMenuOpen(!isAttributeMenuOpen); - attributeToggleRef.current?.focus(); - } - } - }, - [isAttributeMenuOpen, attributeMenuRef, attributeToggleRef], - ); - - const handleAttributeClickOutside = React.useCallback( - (event: MouseEvent) => { - if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { - setIsAttributeMenuOpen(false); - } - }, - [isAttributeMenuOpen, attributeMenuRef], - ); - - React.useEffect(() => { - window.addEventListener('keydown', handleAttributeMenuKeys); - window.addEventListener('click', handleAttributeClickOutside); - return () => { - window.removeEventListener('keydown', handleAttributeMenuKeys); - window.removeEventListener('click', handleAttributeClickOutside); - }; - }, [isAttributeMenuOpen, attributeMenuRef, handleAttributeMenuKeys, handleAttributeClickOutside]); - - const onAttributeToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); // Stop handleClickOutside from handling - setTimeout(() => { - if (attributeMenuRef.current) { - const firstElement = attributeMenuRef.current.querySelector('li > button:not(:disabled)'); - if (firstElement) { - (firstElement as HTMLElement).focus(); - } - } - }, 0); - setIsAttributeMenuOpen(!isAttributeMenuOpen); - }; - - const attributeToggle = ( - } - > - {activeAttributeMenu} - - ); - - const attributeMenu = ( - { - setActiveAttributeMenu(itemId?.toString()); - setIsAttributeMenuOpen(!isAttributeMenuOpen); - }} - > - - - Name - Kind - Image - Pod Config - State - Home Vol - Data Vol - Last Activity - - - - ); - - const attributeDropdown = ( -
- -
- ); - - const toolbar = ( - { - setSearchValue(''); - }} - > - - } breakpoint="xl"> - - {attributeDropdown} - setSearchValue('')} - deleteLabelGroup={() => setSearchValue('')} - categoryName={activeAttributeMenu} - > - {searchInput} - - - - - - - ); - - const onSearchChange = (value: string) => { - setSearchValue(value); - }; +export const Workspaces: React.FunctionComponent = () => { + // change when fetch workspaces is implemented + const initialWorkspaces = mockWorkspaces; + const [workspaces, setWorkspaces] = useState(initialWorkspaces); - const onFilter = (workspace: Workspace) => { + // filter function to pass to the filter component + const onFilter = (activeAttributeMenu: string, searchValue: string) => { // Search name with search value let searchValueInput: RegExp; try { @@ -293,28 +233,28 @@ export const Workspaces: React.FunctionComponent = () => { } catch { searchValueInput = new RegExp(searchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); } - - return ( - searchValue === '' || - (activeAttributeMenu === 'Name' && workspace.name.search(searchValueInput) >= 0) || - (activeAttributeMenu === 'Kind' && workspace.kind.search(searchValueInput) >= 0) || - (activeAttributeMenu === 'Image' && - workspace.options.imageConfig.search(searchValueInput) >= 0) || - (activeAttributeMenu === 'Pod Config' && - workspace.options.podConfig.search(searchValueInput) >= 0) || - (activeAttributeMenu === 'State' && - WorkspaceState[workspace.status.state].search(searchValueInput) >= 0) || - (activeAttributeMenu === 'Home Vol' && - workspace.podTemplate.volumes.home.search(searchValueInput) >= 0) || - (activeAttributeMenu === 'Data Vol' && - workspace.podTemplate.volumes.data.some( - (dataVol) => - dataVol.pvcName.search(searchValueInput) >= 0 || - dataVol.mountPath.search(searchValueInput) >= 0, - )) + const filteredWorkspaces = initialWorkspaces.filter( + (workspace) => + searchValue === '' || + (activeAttributeMenu === 'Name' && workspace.name.search(searchValueInput) >= 0) || + (activeAttributeMenu === 'Kind' && workspace.kind.search(searchValueInput) >= 0) || + (activeAttributeMenu === 'Image' && + workspace.options.imageConfig.search(searchValueInput) >= 0) || + (activeAttributeMenu === 'Pod Config' && + workspace.options.podConfig.search(searchValueInput) >= 0) || + (activeAttributeMenu === 'State' && + WorkspaceState[workspace.status.state].search(searchValueInput) >= 0) || + (activeAttributeMenu === 'Home Vol' && + workspace.podTemplate.volumes.home.search(searchValueInput) >= 0) || + (activeAttributeMenu === 'Data Vol' && + workspace.podTemplate.volumes.data.some( + (dataVol) => + dataVol.pvcName.search(searchValueInput) >= 0 || + dataVol.mountPath.search(searchValueInput) >= 0, + )), ); + setWorkspaces(filteredWorkspaces); }; - const filteredWorkspaces = workspaces.filter(onFilter); // Column sorting @@ -337,7 +277,7 @@ export const Workspaces: React.FunctionComponent = () => { return [name, kind, image, podConfig, state, homeVol, dataVol, cpu, ram, lastActivity]; }; - let sortedWorkspaces = filteredWorkspaces; + let sortedWorkspaces = workspaces; if (activeSortIndex !== null) { sortedWorkspaces = workspaces.sort((a, b) => { const aValue = getSortableRowValues(a)[activeSortIndex]; @@ -434,7 +374,7 @@ export const Workspaces: React.FunctionComponent = () => { Kubeflow Workspaces

View your existing workspaces or create new workspaces.

- {toolbar} + From 6b6f562122eb9096d5083b6b78b64a5f067103d6 Mon Sep 17 00:00:00 2001 From: "Elay Aharoni (EXT-Nokia)" Date: Wed, 11 Dec 2024 19:20:24 +0200 Subject: [PATCH 2/2] feat(ws) add filter to workspaces table Signed-off-by: Elay Aharoni (EXT-Nokia) --- workspaces/frontend/package-lock.json | 309 +++++++----------- workspaces/frontend/package.json | 2 +- .../src/__tests__/cypress/cypress.config.ts | 2 +- .../e2e/workspaces/filterWorkspacesTest.cy.ts | 52 +++ .../workspaces/filterWorkspacesTest.cy.ts | 52 +++ .../src/app/Generic components/Filter.tsx | 166 ---------- .../src/app/pages/Workspaces/Workspaces.tsx | 195 +++-------- .../frontend/src/shared/components/Filter.tsx | 221 +++++++++++++ workspaces/frontend/src/shared/types.ts | 11 + 9 files changed, 513 insertions(+), 497 deletions(-) create mode 100644 workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/workspaces/filterWorkspacesTest.cy.ts create mode 100644 workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/filterWorkspacesTest.cy.ts delete mode 100644 workspaces/frontend/src/app/Generic components/Filter.tsx create mode 100644 workspaces/frontend/src/shared/components/Filter.tsx diff --git a/workspaces/frontend/package-lock.json b/workspaces/frontend/package-lock.json index 5d23d7b0..abc689bb 100644 --- a/workspaces/frontend/package-lock.json +++ b/workspaces/frontend/package-lock.json @@ -36,7 +36,7 @@ "core-js": "^3.39.0", "css-loader": "^6.11.0", "css-minimizer-webpack-plugin": "^5.0.1", - "cypress": "^13.15.0", + "cypress": "^13.16.1", "cypress-axe": "^1.5.0", "cypress-high-resolution": "^1.0.0", "cypress-mochawesome-reporter": "^3.8.2", @@ -227,21 +227,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.25.9.tgz", - "integrity": "sha512-C47lC7LIDCnz0h4vai/tpNOI95tCd5ZT3iBt/DBH5lXKHZsyNQv18yf1wIIg2ntiQNgmAvA+DgZ82iW8Qdym8g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-compilation-targets": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", @@ -298,15 +283,15 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.9.tgz", - "integrity": "sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==", + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz", + "integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", - "regexpu-core": "^6.1.1", + "regexpu-core": "^6.2.0", "semver": "^6.3.1" }, "engines": { @@ -317,9 +302,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz", - "integrity": "sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", + "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", "dev": true, "license": "MIT", "peer": true, @@ -462,21 +447,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-simple-access": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.9.tgz", - "integrity": "sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", @@ -1152,14 +1122,13 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.25.9.tgz", - "integrity": "sha512-KRhdhlVk2nObA5AYa7QMgTMTVJdfHprfpAk4DjZVtllqRg9qarilstTKEhpVjyt+Npi8ThRyiV8176Am3CodPA==", + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz", + "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { @@ -1310,16 +1279,15 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.9.tgz", - "integrity": "sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==", + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz", + "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-module-transforms": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-simple-access": "^7.25.9" + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -3380,6 +3348,18 @@ "integrity": "sha512-xd0ynDkiIW2rp8jz4TNvR4Dyaw9kSMkZdsuYcLlFXCVmvX//Mnl4rhBnid/2j2TaqK0NbkyTTPnPY/BU7SfLVQ==", "license": "MIT" }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pkgr/core": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", @@ -3960,24 +3940,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/eslint": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", - "integrity": "sha512-GE44+DNEyxxh2Kc6ro/VkIj+9ma0pO0bwv9+uHSyBrikYOHr8zYcdPvnBOp1aw8s+CjRvuSx7CyWqRrNFQ59mA==", - "optional": true, - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/estree": { - "version": "0.0.51", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", - "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", - "optional": true, - "peer": true - }, "node_modules/@types/express": { "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", @@ -4098,7 +4060,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "devOptional": true + "dev": true }, "node_modules/@types/json5": { "version": "0.0.29", @@ -4122,14 +4084,6 @@ "undici-types": "~5.26.4" } }, - "node_modules/@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@types/prop-types": { "version": "15.7.3", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", @@ -5370,52 +5324,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/runtime": "^7.12.5", - "cosmiconfig": "^7.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">=10", - "npm": ">=6" - } - }, - "node_modules/babel-plugin-macros/node_modules/resolve": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", - "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "is-core-module": "^2.11.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.11", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.11.tgz", - "integrity": "sha512-sMEJ27L0gRHShOh5G54uAAPaiCOygY/5ratXuiyb2G46FmlSpc9eFCzYVyDiPxfNbwzA7mYahmjQc5q+CZQ09Q==", + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", + "integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.2", + "@babel/helper-define-polyfill-provider": "^0.6.3", "semver": "^6.3.1" }, "peerDependencies": { @@ -5438,14 +5356,14 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.2.tgz", - "integrity": "sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz", + "integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.2" + "@babel/helper-define-polyfill-provider": "^0.6.3" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -6878,24 +6796,6 @@ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true }, - "node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -7361,9 +7261,9 @@ "dev": true }, "node_modules/cypress": { - "version": "13.15.2", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.15.2.tgz", - "integrity": "sha512-ARbnUorjcCM3XiPwgHKuqsyr5W9Qn+pIIBPaoilnoBkLdSC2oLQjV1BUpnmc7KR+b7Avah3Ly2RMFnfxr96E/A==", + "version": "13.16.1", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.16.1.tgz", + "integrity": "sha512-17FtCaz0cx7ssWYKXzGB0Vub8xHwpVPr+iPt2fHhLMDhVAPVrplD+rTQsZUsfb19LVBn5iwkEUFjQ1yVVJXsLA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -10333,20 +10233,6 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "devOptional": true }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/fsu": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/fsu/-/fsu-1.1.1.tgz", @@ -14801,9 +14687,9 @@ } }, "node_modules/mocha": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", - "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.0.1.tgz", + "integrity": "sha512-+3GkODfsDG71KSCQhc4IekSW+ItCK/kiez1Z28ksWvYhKXV/syxMlerR/sC7whDp7IyreZ4YxceMLdTs5hQE8A==", "dev": true, "license": "MIT", "peer": true, @@ -14815,7 +14701,7 @@ "diff": "^5.2.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", - "glob": "^8.1.0", + "glob": "^10.4.5", "he": "^1.2.0", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", @@ -14834,7 +14720,7 @@ "mocha": "bin/mocha.js" }, "engines": { - "node": ">= 14.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/mocha/node_modules/brace-expansion": { @@ -14894,22 +14780,39 @@ } }, "node_modules/mocha/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, "license": "ISC", "peer": true, "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=12" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -14926,6 +14829,23 @@ "node": ">=8" } }, + "node_modules/mocha/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/mocha/node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -14943,6 +14863,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mocha/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC", + "peer": true + }, "node_modules/mocha/node_modules/minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", @@ -14991,6 +14919,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mocha/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -17653,9 +17599,9 @@ } }, "node_modules/regexpu-core": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.1.1.tgz", - "integrity": "sha512-k67Nb9jvwJcJmVpw0jPttR1/zVfnKf8Km0IPatrU/zJ5XeG3+Slx0xLXs9HByJSzXzrlz5EDvN6yLNMDc2qdnw==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", + "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", "dev": true, "license": "MIT", "peer": true, @@ -17663,7 +17609,7 @@ "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.0", "regjsgen": "^0.8.0", - "regjsparser": "^0.11.0", + "regjsparser": "^0.12.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.1.0" }, @@ -17704,9 +17650,9 @@ "peer": true }, "node_modules/regjsparser": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.11.2.tgz", - "integrity": "sha512-3OGZZ4HoLJkkAZx/48mTXJNlmqTGOzc0o9OWQPuWpkOlXXPbyN6OafCcoXUnBqE2D3f/T5L+pWc1kdEmnfnRsA==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", + "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -21419,17 +21365,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/workspaces/frontend/package.json b/workspaces/frontend/package.json index 0625d252..f0b15d26 100644 --- a/workspaces/frontend/package.json +++ b/workspaces/frontend/package.json @@ -51,7 +51,7 @@ "core-js": "^3.39.0", "css-loader": "^6.11.0", "css-minimizer-webpack-plugin": "^5.0.1", - "cypress": "^13.15.0", + "cypress": "^13.16.1", "cypress-axe": "^1.5.0", "cypress-high-resolution": "^1.0.0", "cypress-mochawesome-reporter": "^3.8.2", diff --git a/workspaces/frontend/src/__tests__/cypress/cypress.config.ts b/workspaces/frontend/src/__tests__/cypress/cypress.config.ts index a25c002b..349ff312 100644 --- a/workspaces/frontend/src/__tests__/cypress/cypress.config.ts +++ b/workspaces/frontend/src/__tests__/cypress/cypress.config.ts @@ -48,7 +48,7 @@ export default defineConfig({ }, defaultCommandTimeout: 10000, e2e: { - baseUrl: BASE_URL, + baseUrl: env.CY_MOCK ? BASE_URL : 'http://localhost:9000', specPattern: env.CY_MOCK ? `cypress/tests/mocked/**/*.cy.ts` : `cypress/tests/e2e/**/*.cy.ts`, experimentalInteractiveRunEvents: true, setupNodeEvents(on, config) { diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/workspaces/filterWorkspacesTest.cy.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/workspaces/filterWorkspacesTest.cy.ts new file mode 100644 index 00000000..ebff315c --- /dev/null +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/workspaces/filterWorkspacesTest.cy.ts @@ -0,0 +1,52 @@ +import { home } from '~/__tests__/cypress/cypress/pages/home'; + +const useFilter = (filterName: string, searchValue: string) => { + cy.get("[id$='filter-workspaces-dropdown']").click(); + cy.get(`[id$='filter-workspaces-dropdown-${filterName}']`).click(); + cy.get("[id$='filter-workspaces-search-input']").type(searchValue); + cy.get("[class$='pf-v6-c-toolbar__group']").contains(filterName); + cy.get("[class$='pf-v6-c-toolbar__group']").contains(searchValue); +}; + +describe('Application', () => { + it('filter rows with single filter', () => { + home.visit(); + useFilter('Name', 'My'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); + cy.get("[id$='workspaces-table-row-1']").contains('My Jupyter Notebook'); + cy.get("[id$='workspaces-table-row-2']").contains('My Other Jupyter Notebook'); + }); + + it('filter rows with multiple filters', () => { + home.visit(); + useFilter('Name', 'My'); + useFilter('Pod Config', 'Small'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); + cy.get("[id$='workspaces-table-row-1']").contains('My Jupyter Notebook'); + }); + + it('filter rows with multiple filters and remove one', () => { + home.visit(); + useFilter('Name', 'My'); + useFilter('Pod Config', 'Small'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); + cy.get("[id$='workspaces-table-row-1']").contains('My Jupyter Notebook'); + cy.get("[class$='pf-v6-c-label-group__close']").eq(1).click(); + cy.get("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Pod Config'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); + cy.get("[id$='workspaces-table-row-1']").contains('My Jupyter Notebook'); + cy.get("[id$='workspaces-table-row-2']").contains('My Other Jupyter Notebook'); + }); + + it('filter rows with multiple filters and remove all', () => { + home.visit(); + useFilter('Name', 'My'); + useFilter('Pod Config', 'Small'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); + cy.get("[id$='workspaces-table-row-1']").contains('My Jupyter Notebook'); + cy.get('*').contains('Clear all filters').click(); + cy.get("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Pod Config'); + cy.get("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Name'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); + }); +}); diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/filterWorkspacesTest.cy.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/filterWorkspacesTest.cy.ts new file mode 100644 index 00000000..ebff315c --- /dev/null +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/filterWorkspacesTest.cy.ts @@ -0,0 +1,52 @@ +import { home } from '~/__tests__/cypress/cypress/pages/home'; + +const useFilter = (filterName: string, searchValue: string) => { + cy.get("[id$='filter-workspaces-dropdown']").click(); + cy.get(`[id$='filter-workspaces-dropdown-${filterName}']`).click(); + cy.get("[id$='filter-workspaces-search-input']").type(searchValue); + cy.get("[class$='pf-v6-c-toolbar__group']").contains(filterName); + cy.get("[class$='pf-v6-c-toolbar__group']").contains(searchValue); +}; + +describe('Application', () => { + it('filter rows with single filter', () => { + home.visit(); + useFilter('Name', 'My'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); + cy.get("[id$='workspaces-table-row-1']").contains('My Jupyter Notebook'); + cy.get("[id$='workspaces-table-row-2']").contains('My Other Jupyter Notebook'); + }); + + it('filter rows with multiple filters', () => { + home.visit(); + useFilter('Name', 'My'); + useFilter('Pod Config', 'Small'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); + cy.get("[id$='workspaces-table-row-1']").contains('My Jupyter Notebook'); + }); + + it('filter rows with multiple filters and remove one', () => { + home.visit(); + useFilter('Name', 'My'); + useFilter('Pod Config', 'Small'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); + cy.get("[id$='workspaces-table-row-1']").contains('My Jupyter Notebook'); + cy.get("[class$='pf-v6-c-label-group__close']").eq(1).click(); + cy.get("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Pod Config'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); + cy.get("[id$='workspaces-table-row-1']").contains('My Jupyter Notebook'); + cy.get("[id$='workspaces-table-row-2']").contains('My Other Jupyter Notebook'); + }); + + it('filter rows with multiple filters and remove all', () => { + home.visit(); + useFilter('Name', 'My'); + useFilter('Pod Config', 'Small'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); + cy.get("[id$='workspaces-table-row-1']").contains('My Jupyter Notebook'); + cy.get('*').contains('Clear all filters').click(); + cy.get("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Pod Config'); + cy.get("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Name'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); + }); +}); diff --git a/workspaces/frontend/src/app/Generic components/Filter.tsx b/workspaces/frontend/src/app/Generic components/Filter.tsx deleted file mode 100644 index d287dd60..00000000 --- a/workspaces/frontend/src/app/Generic components/Filter.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import * as React from 'react'; -import { - Button, - Menu, - MenuContent, - MenuItem, - MenuList, - MenuToggle, - MenuToggleElement, - Popper, - SearchInput, - Toolbar, - ToolbarContent, - ToolbarFilter, - ToolbarGroup, - ToolbarItem, - ToolbarToggleGroup, -} from '@patternfly/react-core'; -import { FilterIcon } from '@patternfly/react-icons'; - -interface FilterProps { - onFilter: (activeAttributeMenu: string, value: string) => void; - columnNames: { [key: string]: string }; -} - -const Filter: React.FC = ({ onFilter, columnNames }) => { - const [activeFilter, setActiveFilter] = React.useState(Object.values(columnNames)[0]); - const [searchValue, setSearchValue] = React.useState(''); - const [isFilterMenuOpen, setIsFilterMenuOpen] = React.useState(false); - - const filterToggleRef = React.useRef(null); - const filterMenuRef = React.useRef(null); - const filterContainerRef = React.useRef(null); - - const handlefilterMenuKeys = React.useCallback( - (event: KeyboardEvent) => { - if (!isFilterMenuOpen) { - return; - } - if ( - filterMenuRef.current?.contains(event.target as Node) || - filterToggleRef.current?.contains(event.target as Node) - ) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsFilterMenuOpen(!isFilterMenuOpen); - filterToggleRef.current?.focus(); - } - } - }, - [isFilterMenuOpen, filterMenuRef, filterToggleRef], - ); - - const handleClickOutside = React.useCallback( - (event: MouseEvent) => { - if (isFilterMenuOpen && !filterMenuRef.current?.contains(event.target as Node)) { - setIsFilterMenuOpen(false); - } - }, - [isFilterMenuOpen, filterMenuRef], - ); - - React.useEffect(() => { - window.addEventListener('keydown', handlefilterMenuKeys); - window.addEventListener('click', handleClickOutside); - return () => { - window.removeEventListener('keydown', handlefilterMenuKeys); - window.removeEventListener('click', handleClickOutside); - }; - }, [isFilterMenuOpen, filterMenuRef, handlefilterMenuKeys, handleClickOutside]); - - const onFilterToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); // Stop handleClickOutside from handling - setTimeout(() => { - if (filterMenuRef.current) { - const firstElement = filterMenuRef.current.querySelector('li > button:not(:disabled)'); - if (firstElement) { - (firstElement as HTMLElement).focus(); - } - } - }, 0); - setIsFilterMenuOpen(!isFilterMenuOpen); - }; - - const FilterMenuToggle = ( - } - > - {activeFilter} - - ); - - const filterMenu = ( - { - setActiveFilter(itemId); - setIsFilterMenuOpen(!isFilterMenuOpen); - }} - > - - - {Object.values(columnNames).map((name: string) => ( - - {name} - - ))} - - - - ); - - const filterDropdown = ( -
- -
- ); - - const onSearchChange = (value: string) => { - setSearchValue(value); - onFilter(activeFilter, value); - }; - - return ( - { - onSearchChange(''); - }} - > - - } breakpoint="xl"> - - {filterDropdown} - setSearchValue('')} - deleteLabelGroup={() => setSearchValue('')} - categoryName={activeFilter} - > - onSearchChange(value)} - onClear={() => onSearchChange('')} - /> - - - - - - - ); -}; -export default Filter; diff --git a/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx b/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx index 3b50959b..0d070638 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx @@ -20,96 +20,12 @@ import { IActions, } from '@patternfly/react-table'; import { useState } from 'react'; -import { Workspace, WorkspaceState } from '~/shared/types'; -import Filter from '~/app/Generic components/Filter'; -import { FilterIcon } from '@patternfly/react-icons'; import { Workspace, WorkspaceState } from 'shared/types'; import { formatRam } from 'shared/utilities/WorkspaceResources'; +import Filter from 'shared/components/Filter'; -/* Mocked workspaces, to be removed after fetching info from backend */ -const mockWorkspaces: Workspace[] = [ - { - name: 'My Jupyter Notebook', - namespace: 'namespace1', - paused: true, - deferUpdates: true, - kind: 'jupyter-lab', - podTemplate: { - volumes: { - home: '/home', - data: [ - { - pvcName: 'data', - mountPath: '/data', - readOnly: false, - }, - ], - }, - }, - options: { - imageConfig: 'jupyterlab_scipy_180', - podConfig: 'Small CPU', - }, - status: { - activity: { - lastActivity: 0, - lastUpdate: 0, - }, - pauseTime: 0, - pendingRestart: false, - podTemplateOptions: { - imageConfig: { - desired: '', - redirectChain: [], - }, - }, - state: WorkspaceState.Paused, - stateMessage: 'It is paused.', - }, - }, - { - name: 'My Other Jupyter Notebook', - namespace: 'namespace1', - paused: false, - deferUpdates: false, - kind: 'jupyter-lab', - podTemplate: { - volumes: { - home: '/home', - data: [ - { - pvcName: 'data', - mountPath: '/data', - readOnly: false, - }, - ], - }, - }, - options: { - imageConfig: 'jupyterlab_scipy_180', - podConfig: 'Large CPU', - }, - status: { - activity: { - lastActivity: 0, - lastUpdate: 0, - }, - pauseTime: 0, - pendingRestart: false, - podTemplateOptions: { - imageConfig: { - desired: '', - redirectChain: [], - }, - }, - state: WorkspaceState.Running, - stateMessage: 'It is running.', - }, - }, -]; export const Workspaces: React.FunctionComponent = () => { - /* Mocked workspaces, to be removed after fetching info from backend */ - const workspaces: Workspace[] = [ + const mockWorkspaces: Workspace[] = [ { name: 'My Jupyter Notebook', namespace: 'namespace1', @@ -194,17 +110,6 @@ export const Workspaces: React.FunctionComponent = () => { }, ]; -// Table columns -const columnNames = { - name: 'Name', - kind: 'Kind', - image: 'Image', - podConfig: 'Pod Config', - state: 'State', - homeVol: 'Home Vol', - dataVol: 'Data Vol', - lastActivity: 'Last Activity', -}; // Table columns const columnNames = { name: 'Name', @@ -219,40 +124,55 @@ const columnNames = { lastActivity: 'Last Activity', }; -export const Workspaces: React.FunctionComponent = () => { // change when fetch workspaces is implemented const initialWorkspaces = mockWorkspaces; const [workspaces, setWorkspaces] = useState(initialWorkspaces); // filter function to pass to the filter component - const onFilter = (activeAttributeMenu: string, searchValue: string) => { + const onFilter = (filters: { filterName: string; value: string }[]) => { // Search name with search value - let searchValueInput: RegExp; - try { - searchValueInput = new RegExp(searchValue, 'i'); - } catch { - searchValueInput = new RegExp(searchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); - } - const filteredWorkspaces = initialWorkspaces.filter( - (workspace) => - searchValue === '' || - (activeAttributeMenu === 'Name' && workspace.name.search(searchValueInput) >= 0) || - (activeAttributeMenu === 'Kind' && workspace.kind.search(searchValueInput) >= 0) || - (activeAttributeMenu === 'Image' && - workspace.options.imageConfig.search(searchValueInput) >= 0) || - (activeAttributeMenu === 'Pod Config' && - workspace.options.podConfig.search(searchValueInput) >= 0) || - (activeAttributeMenu === 'State' && - WorkspaceState[workspace.status.state].search(searchValueInput) >= 0) || - (activeAttributeMenu === 'Home Vol' && - workspace.podTemplate.volumes.home.search(searchValueInput) >= 0) || - (activeAttributeMenu === 'Data Vol' && - workspace.podTemplate.volumes.data.some( - (dataVol) => - dataVol.pvcName.search(searchValueInput) >= 0 || - dataVol.mountPath.search(searchValueInput) >= 0, - )), - ); + let filteredWorkspaces = initialWorkspaces; + filters.forEach((filter) => { + let searchValueInput: RegExp; + try { + searchValueInput = new RegExp(filter.value, 'i'); + } catch { + searchValueInput = new RegExp(filter.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + // eslint-disable-next-line array-callback-return + filteredWorkspaces = filteredWorkspaces.filter((workspace) => { + if (filter.value === '') { + return true; + } + switch (filter.filterName) { + case 'Name': + return workspace.name.search(searchValueInput) >= 0; + case 'Kind': + return workspace.kind.search(searchValueInput) >= 0; + case 'Image': + return workspace.options.imageConfig.search(searchValueInput) >= 0; + case 'Pod Config': + return workspace.options.podConfig.search(searchValueInput) >= 0; + case 'State': + return WorkspaceState[workspace.status.state].search(searchValueInput) >= 0; + case 'Home Vol': + return workspace.podTemplate.volumes.home.search(searchValueInput) >= 0; + case 'Data Vol': + return workspace.podTemplate.volumes.data.some( + (dataVol) => + dataVol.pvcName.search(searchValueInput) >= 0 || + dataVol.mountPath.search(searchValueInput) >= 0, + ); + case 'CPU': + return workspace.cpu.toString().search(searchValueInput) >= 0; + case 'Memory': + return workspace.ram.toString().search(searchValueInput) >= 0; + default: + } + }); + }); setWorkspaces(filteredWorkspaces); }; @@ -374,30 +294,21 @@ export const Workspaces: React.FunctionComponent = () => { Kubeflow Workspaces

View your existing workspaces or create new workspaces.

- +
- - - - - - - - - - + {Object.values(columnNames).map((columnName, index) => ( + + ))} - + {sortedWorkspaces.map((workspace, rowIndex) => ( - + diff --git a/workspaces/frontend/src/shared/components/Filter.tsx b/workspaces/frontend/src/shared/components/Filter.tsx new file mode 100644 index 00000000..0e358e67 --- /dev/null +++ b/workspaces/frontend/src/shared/components/Filter.tsx @@ -0,0 +1,221 @@ +import * as React from 'react'; +import { + Button, + Menu, + MenuContent, + MenuItem, + MenuList, + MenuToggle, + MenuToggleElement, + Popper, + SearchInput, + Toolbar, + ToolbarContent, + ToolbarFilter, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons'; +import { FilterObj, FilterProps } from '~/shared/types'; + +const Filter: React.FC = ({ id, onFilter, columnNames }) => { + const [activeFilter, setActiveFilter] = React.useState({ + filterName: Object.values(columnNames)[0], + value: '', + }); + const [searchValue, setSearchValue] = React.useState(''); + const [isFilterMenuOpen, setIsFilterMenuOpen] = React.useState(false); + const [filters, setFilters] = React.useState([]); + + const filterToggleRef = React.useRef(null); + const filterMenuRef = React.useRef(null); + const filterContainerRef = React.useRef(null); + + const handleFilterMenuKeys = React.useCallback( + (event: KeyboardEvent) => { + if (!isFilterMenuOpen) { + return; + } + if ( + filterMenuRef.current?.contains(event.target as Node) || + filterToggleRef.current?.contains(event.target as Node) + ) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsFilterMenuOpen(!isFilterMenuOpen); + filterToggleRef.current?.focus(); + } + } + }, + [isFilterMenuOpen, filterMenuRef, filterToggleRef], + ); + + const handleClickOutside = React.useCallback( + (event: MouseEvent) => { + if (isFilterMenuOpen && !filterMenuRef.current?.contains(event.target as Node)) { + setIsFilterMenuOpen(false); + } + }, + [isFilterMenuOpen, filterMenuRef], + ); + + React.useEffect(() => { + window.addEventListener('keydown', handleFilterMenuKeys); + window.addEventListener('click', handleClickOutside); + return () => { + window.removeEventListener('keydown', handleFilterMenuKeys); + window.removeEventListener('click', handleClickOutside); + }; + }, [isFilterMenuOpen, filterMenuRef, handleFilterMenuKeys, handleClickOutside]); + + const onFilterToggleClick = React.useCallback( + (ev: React.MouseEvent) => { + ev.stopPropagation(); // Stop handleClickOutside from handling + if (filterMenuRef.current) { + const firstElement = filterMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + setIsFilterMenuOpen(!isFilterMenuOpen); + }, + [isFilterMenuOpen], + ); + + const addFilter = React.useCallback( + (filterObj: FilterObj) => { + const index = filters.findIndex((filter) => filter.filterName === filterObj.filterName); + const newFilters = filters; + if (index !== -1) { + newFilters[index] = filterObj; + } else { + newFilters.push(filterObj); + } + setFilters(newFilters); + }, + [filters], + ); + + const onSearchChange = React.useCallback( + (value: string) => { + const newFilter = { filterName: activeFilter.filterName, value }; + setSearchValue(value); + setActiveFilter(newFilter); + addFilter(newFilter); + onFilter(filters); + }, + [activeFilter.filterName, addFilter, filters, onFilter], + ); + + const onDeleteLabelGroup = React.useCallback( + (filter: FilterObj) => { + const newFilters = filters.filter((filter1) => filter1.filterName !== filter.filterName); + setFilters(newFilters); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + filter.filterName === activeFilter.filterName && setSearchValue(''); + onFilter(newFilters); + }, + [activeFilter.filterName, filters, onFilter], + ); + + const onFilterSelect = React.useCallback( + (itemId: string | number | undefined) => { + setIsFilterMenuOpen(!isFilterMenuOpen); + const index = filters.findIndex((filter) => filter.filterName === itemId); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + index === -1 ? setSearchValue('') : setSearchValue(filters[index].value); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + setActiveFilter({ filterName: itemId, value: searchValue }); + }, + [filters, isFilterMenuOpen, searchValue], + ); + + const FilterMenuToggle = React.useMemo( + () => ( + } + > + {activeFilter.filterName} + + ), + [activeFilter.filterName, isFilterMenuOpen, onFilterToggleClick], + ); + + const filterMenu = React.useMemo( + () => ( + onFilterSelect(itemId)}> + + + {Object.values(columnNames).map((name: string) => ( + + {name} + + ))} + + + + ), + [columnNames, id, onFilterSelect], + ); + + const filterDropdown = React.useMemo( + () => ( +
+ +
+ ), + [FilterMenuToggle, filterMenu, isFilterMenuOpen], + ); + + return ( + { + setFilters([]); + setSearchValue(''); + onFilter([]); + }} + > + + } breakpoint="xl"> + {filterDropdown} + + {filters.map((filter) => ( + onDeleteLabelGroup(filter)} + deleteLabelGroup={() => onDeleteLabelGroup(filter)} + categoryName={filter.filterName} + > + {undefined} + + ))} + + onSearchChange(value)} + onClear={() => onSearchChange('')} + /> + + + + + ); +}; +export default Filter; diff --git a/workspaces/frontend/src/shared/types.ts b/workspaces/frontend/src/shared/types.ts index 6cb433e3..8542020a 100644 --- a/workspaces/frontend/src/shared/types.ts +++ b/workspaces/frontend/src/shared/types.ts @@ -70,3 +70,14 @@ export interface Workspace { }; status: WorkspaceStatus; } + +export interface FilterProps { + id: string; + onFilter: (filters: { filterName: string; value: string }[]) => void; + columnNames: { [key: string]: string }; +} + +export interface FilterObj { + filterName: string; + value: string; +}
{columnNames.name}{columnNames.kind}{columnNames.image}{columnNames.podConfig}{columnNames.state}{columnNames.homeVol}{columnNames.dataVol} - {columnNames.cpu} - - {columnNames.ram} - {columnNames.lastActivity} + {columnName} +
{workspace.name} {workspace.kind} {workspace.options.imageConfig}