From 055150bb2e554608327ee4e444934ca874810f31 Mon Sep 17 00:00:00 2001 From: Liav Weiss <74174727+liavweiss@users.noreply.github.com> Date: Wed, 29 Jan 2025 20:05:51 +0200 Subject: [PATCH] feat(ws): Notebooks 2.0 // Frontend // Workspaces table // Workspace Kind column #148 (#177) * Merge notebooks-v2 into kind_logo_modification/#148 branch Signed-off-by: Liav Weiss (EXT-Nokia) * feat(ws): Notebooks 2.0 // Frontend // Workspaces table // Workspace Kind column #148 Signed-off-by: Liav Weiss (EXT-Nokia) * feat(ws): Notebooks 2.0 // Frontend // Workspaces table // Workspace Kind column #148 Signed-off-by: Liav Weiss (EXT-Nokia) * feat(ws): Notebooks 2.0 // Frontend // Workspaces table // Workspace Kind column #148 Signed-off-by: Liav Weiss (EXT-Nokia) * feat(ws): Notebooks 2.0 // Frontend // Workspaces table // Workspace Kind column #148 Signed-off-by: Liav Weiss (EXT-Nokia) * feat(ws): Notebooks 2.0 // Frontend // Workspaces table // Workspace Kind column #148 Signed-off-by: Liav Weiss (EXT-Nokia) * feat(ws): Notebooks 2.0 // Frontend // Workspaces table // Workspace Kind column #148 Signed-off-by: Liav Weiss (EXT-Nokia) --------- Signed-off-by: Liav Weiss (EXT-Nokia) Co-authored-by: Liav Weiss (EXT-Nokia) --- workspaces/frontend/package-lock.json | 10 ++- workspaces/frontend/package.json | 2 +- .../cypress/tests/e2e/workspaceKind.cy.ts | 65 ++++++++++++++++ .../tests/mocked/workspaceKinds.mock.ts | 74 +++++++++++++++++++ .../src/app/actions/WorkspaceKindsActions.tsx | 21 ++++++ .../src/app/context/useNotebookAPIState.tsx | 3 +- .../src/app/hooks/useWorkspaceKinds.ts | 24 ++++++ .../src/app/pages/Workspaces/Workspaces.tsx | 25 ++++++- workspaces/frontend/src/app/types.ts | 4 + .../src/shared/api/notebookService.ts | 11 +++ workspaces/frontend/src/shared/types.ts | 53 ++++++++++++- 11 files changed, 283 insertions(+), 9 deletions(-) create mode 100644 workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/workspaceKind.cy.ts create mode 100644 workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaceKinds.mock.ts create mode 100644 workspaces/frontend/src/app/actions/WorkspaceKindsActions.tsx create mode 100644 workspaces/frontend/src/app/hooks/useWorkspaceKinds.ts diff --git a/workspaces/frontend/package-lock.json b/workspaces/frontend/package-lock.json index 424102be..a5db00e2 100644 --- a/workspaces/frontend/package-lock.json +++ b/workspaces/frontend/package-lock.json @@ -60,7 +60,7 @@ "react-router-dom": "^6.26.1", "regenerator-runtime": "^0.13.11", "rimraf": "^6.0.1", - "sass": "^1.83.1", + "sass": "^1.83.4", "sass-loader": "^16.0.4", "serve": "^14.2.1", "style-loader": "^3.3.4", @@ -18869,10 +18869,11 @@ "dev": true }, "node_modules/sass": { - "version": "1.83.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.83.1.tgz", - "integrity": "sha512-EVJbDaEs4Rr3F0glJzFSOvtg2/oy2V/YrGFPqPY24UqcLDWcI9ZY5sN+qyO3c/QCZwzgfirvhXvINiJCE/OLcA==", + "version": "1.83.4", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.83.4.tgz", + "integrity": "sha512-B1bozCeNQiOgDcLd33e2Cs2U60wZwjUUXzh900ZyQF5qUasvMdDZYbQ566LJu7cqR+sAHlAfO6RMkaID5s6qpA==", "dev": true, + "license": "MIT", "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -18893,6 +18894,7 @@ "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.4.tgz", "integrity": "sha512-LavLbgbBGUt3wCiYzhuLLu65+fWXaXLmq7YxivLhEqmiupCFZ5sKUAipK3do6V80YSU0jvSxNhEdT13IXNr3rg==", "dev": true, + "license": "MIT", "dependencies": { "neo-async": "^2.6.2" }, diff --git a/workspaces/frontend/package.json b/workspaces/frontend/package.json index 292cf51a..88553576 100644 --- a/workspaces/frontend/package.json +++ b/workspaces/frontend/package.json @@ -75,7 +75,7 @@ "react-router-dom": "^6.26.1", "regenerator-runtime": "^0.13.11", "rimraf": "^6.0.1", - "sass": "^1.83.1", + "sass": "^1.83.4", "sass-loader": "^16.0.4", "serve": "^14.2.1", "style-loader": "^3.3.4", diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/workspaceKind.cy.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/workspaceKind.cy.ts new file mode 100644 index 00000000..ba810528 --- /dev/null +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/workspaceKind.cy.ts @@ -0,0 +1,65 @@ +import { + mockWorkspaceKindsInValid, + mockWorkspaceKindsValid, +} from '~/__tests__/cypress/cypress/tests/mocked/workspaceKinds.mock'; + +describe('Test buildKindLogoDictionary Functionality', () => { + // Mock valid workspace kinds + context('With Valid Data', () => { + before(() => { + // Mock the API response + cy.intercept('GET', '/api/v1/workspacekinds', { + statusCode: 200, + body: mockWorkspaceKindsValid, + }); + + // Visit the page + cy.visit('/'); + }); + + it('should fetch and populate kind logos', () => { + // Check that the logos are rendered in the table + cy.get('tbody tr').each(($row) => { + cy.wrap($row) + .find('td[data-label="Kind"]') + .within(() => { + cy.get('img') + .should('exist') + .then(($img) => { + // Ensure the image is fully loaded + cy.wrap($img[0]).should('have.prop', 'complete', true); + }); + }); + }); + }); + }); + + // Mock invalid workspace kinds + context('With Invalid Data', () => { + before(() => { + // Mock the API response for invalid workspace kinds + cy.intercept('GET', '/api/v1/workspacekinds', { + statusCode: 200, + body: mockWorkspaceKindsInValid, + }); + + // Visit the page + cy.visit('/'); + }); + + it('should show a fallback icon when the logo URL is missing', () => { + cy.get('tbody tr').each(($row) => { + cy.wrap($row) + .find('td[data-label="Kind"]') + .within(() => { + // Ensure that the image is NOT rendered (because it's invalid or missing) + cy.get('img').should('not.exist'); // No images should be displayed + + // Check if the fallback icon (TimesCircleIcon) is displayed + cy.get('svg').should('exist'); // Look for the SVG (TimesCircleIcon) + cy.get('svg').should('have.class', 'pf-v6-svg'); // Ensure the correct fallback icon class is applied (update the class name based on your icon library) + }); + }); + }); + }); +}); diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaceKinds.mock.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaceKinds.mock.ts new file mode 100644 index 00000000..03c0b05f --- /dev/null +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaceKinds.mock.ts @@ -0,0 +1,74 @@ +import type { WorkspaceKind } from '~/shared/types'; + +// Factory function to create a valid WorkspaceKind +function createMockWorkspaceKind(overrides: Partial = {}): WorkspaceKind { + return { + name: 'jupyter-lab', + displayName: 'JupyterLab Notebook', + description: 'A Workspace which runs JupyterLab in a Pod', + deprecated: false, + deprecationMessage: '', + hidden: false, + icon: { + url: 'https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png', + }, + logo: { + url: 'https://upload.wikimedia.org/wikipedia/commons/3/38/Jupyter_logo.svg', + }, + podTemplate: { + podMetadata: { + labels: { myWorkspaceKindLabel: 'my-value' }, + annotations: { myWorkspaceKindAnnotation: 'my-value' }, + }, + volumeMounts: { home: '/home/jovyan' }, + options: { + imageConfig: { + default: 'jupyterlab_scipy_190', + values: [ + { + id: 'jupyterlab_scipy_180', + displayName: 'jupyter-scipy:v1.8.0', + labels: { pythonVersion: '3.11' }, + hidden: true, + redirect: { + to: 'jupyterlab_scipy_190', + message: { + text: 'This update will change...', + level: 'Info', + }, + }, + }, + ], + }, + podConfig: { + default: 'tiny_cpu', + values: [ + { + id: 'tiny_cpu', + displayName: 'Tiny CPU', + description: 'Pod with 0.1 CPU, 128 Mb RAM', + labels: { cpu: '100m', memory: '128Mi' }, + }, + ], + }, + }, + }, + ...overrides, // Allows customization + }; +} + +// Generate valid mock data with "data" property +export const mockWorkspaceKindsValid = { + data: [createMockWorkspaceKind()], +}; + +// Generate invalid mock data with "data" property +export const mockWorkspaceKindsInValid = { + data: [ + createMockWorkspaceKind({ + logo: { + url: '', + }, + }), + ], +}; diff --git a/workspaces/frontend/src/app/actions/WorkspaceKindsActions.tsx b/workspaces/frontend/src/app/actions/WorkspaceKindsActions.tsx new file mode 100644 index 00000000..5bc1abc8 --- /dev/null +++ b/workspaces/frontend/src/app/actions/WorkspaceKindsActions.tsx @@ -0,0 +1,21 @@ +import { WorkspaceKind } from '~/shared/types'; + +type KindLogoDict = Record; + +/** + * Builds a dictionary of kind names to logos, and returns it. + * @param {WorkspaceKind[]} workspaceKinds - The list of workspace kinds. + * @returns {KindLogoDict} A dictionary with kind names as keys and logo URLs as values. + */ +export function buildKindLogoDictionary(workspaceKinds: WorkspaceKind[] | []): KindLogoDict { + const kindLogoDict: KindLogoDict = {}; + + for (const workspaceKind of workspaceKinds) { + try { + kindLogoDict[workspaceKind.name] = workspaceKind.logo.url; + } catch { + kindLogoDict[workspaceKind.name] = ''; + } + } + return kindLogoDict; +} diff --git a/workspaces/frontend/src/app/context/useNotebookAPIState.tsx b/workspaces/frontend/src/app/context/useNotebookAPIState.tsx index cb078d08..983f9186 100644 --- a/workspaces/frontend/src/app/context/useNotebookAPIState.tsx +++ b/workspaces/frontend/src/app/context/useNotebookAPIState.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { APIState } from '~/shared/api/types'; import { NotebookAPIs } from '~/app/types'; -import { getNamespaces } from '~/shared/api/notebookService'; +import { getNamespaces, getWorkspaceKinds } from '~/shared/api/notebookService'; import useAPIState from '~/shared/api/useAPIState'; export type NotebookAPIState = APIState; @@ -12,6 +12,7 @@ const useNotebookAPIState = ( const createAPI = React.useCallback( (path: string) => ({ getNamespaces: getNamespaces(path), + getWorkspaceKinds: getWorkspaceKinds(path), }), [], ); diff --git a/workspaces/frontend/src/app/hooks/useWorkspaceKinds.ts b/workspaces/frontend/src/app/hooks/useWorkspaceKinds.ts new file mode 100644 index 00000000..f0ad1846 --- /dev/null +++ b/workspaces/frontend/src/app/hooks/useWorkspaceKinds.ts @@ -0,0 +1,24 @@ +import * as React from 'react'; +import useFetchState, { + FetchState, + FetchStateCallbackPromise, +} from '~/shared/utilities/useFetchState'; +import { WorkspaceKind } from '~/shared/types'; +import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; + +const useWorkspaceKinds = (): FetchState => { + const { api, apiAvailable } = useNotebookAPI(); + const call = React.useCallback>( + (opts) => { + if (!apiAvailable) { + return Promise.reject(new Error('API not yet available')); + } + return api.getWorkspaceKinds(opts); + }, + [api, apiAvailable], + ); + + return useFetchState(call, []); +}; + +export default useWorkspaceKinds; diff --git a/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx b/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx index 3b3d9aef..fbf54b8d 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx @@ -11,6 +11,8 @@ import { Pagination, Button, Content, + Tooltip, + Brand, } from '@patternfly/react-core'; import { Table, @@ -24,10 +26,13 @@ import { IActions, } from '@patternfly/react-table'; import { useState } from 'react'; +import { CodeIcon } from '@patternfly/react-icons'; import { Workspace, WorkspacesColumnNames, WorkspaceState } from '~/shared/types'; import { WorkspaceDetails } from '~/app/pages/Workspaces/Details/WorkspaceDetails'; import { ExpandedWorkspaceRow } from '~/app/pages/Workspaces/ExpandedWorkspaceRow'; import DeleteModal from '~/shared/components/DeleteModal'; +import { buildKindLogoDictionary } from '~/app/actions/WorkspaceKindsActions'; +import useWorkspaceKinds from '~/app/hooks/useWorkspaceKinds'; import Filter, { FilteredColumn } from 'shared/components/Filter'; import { formatRam } from 'shared/utilities/WorkspaceResources'; @@ -131,6 +136,10 @@ export const Workspaces: React.FunctionComponent = () => { }, ]; + const [workspaceKinds] = useWorkspaceKinds(); + let kindLogoDict: Record = {}; + kindLogoDict = buildKindLogoDictionary(workspaceKinds); + // Table columns const columnNames: WorkspacesColumnNames = { name: 'Name', @@ -419,7 +428,21 @@ export const Workspaces: React.FunctionComponent = () => { }} /> {workspace.name} - {workspace.kind} + + {kindLogoDict[workspace.kind] ? ( + + + + ) : ( + + + + )} + {workspace.options.imageConfig} {workspace.options.podConfig} diff --git a/workspaces/frontend/src/app/types.ts b/workspaces/frontend/src/app/types.ts index ca80e9cd..26ba3e1f 100644 --- a/workspaces/frontend/src/app/types.ts +++ b/workspaces/frontend/src/app/types.ts @@ -1,4 +1,5 @@ import { APIOptions } from '~/shared/api/types'; +import { WorkspaceKind } from '~/shared/types'; export type ResponseBody = { data: T; @@ -64,6 +65,9 @@ export type NamespacesList = Namespace[]; export type GetNamespaces = (opts: APIOptions) => Promise; +export type GetWorkspaceKinds = (opts: APIOptions) => Promise; + export type NotebookAPIs = { getNamespaces: GetNamespaces; + getWorkspaceKinds: GetWorkspaceKinds; }; diff --git a/workspaces/frontend/src/shared/api/notebookService.ts b/workspaces/frontend/src/shared/api/notebookService.ts index 5f38a5cc..e676c222 100644 --- a/workspaces/frontend/src/shared/api/notebookService.ts +++ b/workspaces/frontend/src/shared/api/notebookService.ts @@ -2,6 +2,7 @@ import { NamespacesList } from '~/app/types'; import { isNotebookResponse, restGET } from '~/shared/api/apiUtils'; import { APIOptions } from '~/shared/api/types'; import { handleRestFailures } from '~/shared/api/errorUtils'; +import { WorkspaceKind } from '~/shared/types'; export const getNamespaces = (hostPath: string) => @@ -12,3 +13,13 @@ export const getNamespaces = } throw new Error('Invalid response format'); }); + +export const getWorkspaceKinds = + (hostPath: string) => + (opts: APIOptions): Promise => + handleRestFailures(restGET(hostPath, `/workspacekinds`, {}, opts)).then((response) => { + if (isNotebookResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); diff --git a/workspaces/frontend/src/shared/types.ts b/workspaces/frontend/src/shared/types.ts index 577c371b..d7857ced 100644 --- a/workspaces/frontend/src/shared/types.ts +++ b/workspaces/frontend/src/shared/types.ts @@ -10,11 +10,60 @@ export interface WorkspaceKind { name: string; displayName: string; description: string; - hidden: boolean; deprecated: boolean; - deprecationWarning: string; + deprecationMessage: string; + hidden: boolean; icon: WorkspaceIcon; logo: WorkspaceLogo; + podTemplate: { + podMetadata: { + labels: { + myWorkspaceKindLabel: string; + }; + annotations: { + myWorkspaceKindAnnotation: string; + }; + }; + volumeMounts: { + home: string; + }; + options: { + imageConfig: { + default: string; + values: [ + { + id: string; + displayName: string; + labels: { + pythonVersion: string; + }; + hidden: true; + redirect: { + to: string; + message: { + text: string; + level: string; + }; + }; + }, + ]; + }; + podConfig: { + default: string; + values: [ + { + id: string; + displayName: string; + description: string; + labels: { + cpu: string; + memory: string; + }; + }, + ]; + }; + }; + }; } export enum WorkspaceState {