From 7fa255c0277de9cd7e9467248435f848e147e42d Mon Sep 17 00:00:00 2001 From: Jenny <32821331+jenny-s51@users.noreply.github.com> Date: Wed, 4 Jun 2025 14:53:11 -0400 Subject: [PATCH] fix(ws): updates to table columns Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com> add icon to workspaceKindsColumns interface fix(ws): Update table with expandable variant and fix styles fix secondary border in menu toggle fix menu toggle expanded text color and update icon to use status prop remove unused files add cluster storage description list group Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com> Add title and packages revert form label styling, revert homeVol column fix linting fix lint Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com> Add PR code suggestions, remove unused interfaces Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com> remove unused import Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com> fix filterWorkspacesTest Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com> fix(ws): apply feedback to fix Cypress tests Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com> Update tests, add width to defineDataFields, remove duplicate WorkspaceTableColumnKeys type Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com> fix wrapping behavior Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com> Replace Th values with mapped instance Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com> revert column order Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com> remove hardcoded package label instances Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com> delete cursor rule --- .../workspaces/WorkspaceDetailsActivity.cy.ts | 7 +- .../tests/mocked/workspaces/Workspaces.cy.ts | 7 +- .../workspaces/filterWorkspacesTest.cy.ts | 41 ++- .../src/app/components/WorkspaceTable.tsx | 342 +++++++++--------- .../frontend/src/app/filterableDataHelper.ts | 1 + .../summary/WorkspaceKindSummary.tsx | 2 +- .../app/pages/Workspaces/DataVolumesList.tsx | 50 +-- .../pages/Workspaces/ExpandedWorkspaceRow.tsx | 72 +++- .../Workspaces/WorkspaceConfigDetails.tsx | 36 ++ .../Workspaces/WorkspaceConnectAction.tsx | 2 + .../Workspaces/WorkspacePackageDetails.tsx | 38 ++ .../app/pages/Workspaces/WorkspaceStorage.tsx | 27 ++ .../frontend/src/shared/mock/mockBuilder.ts | 38 +- .../frontend/src/shared/style/MUI-theme.scss | 44 ++- .../src/shared/utilities/WorkspaceUtils.ts | 23 +- 15 files changed, 471 insertions(+), 259 deletions(-) create mode 100644 workspaces/frontend/src/app/pages/Workspaces/WorkspaceConfigDetails.tsx create mode 100644 workspaces/frontend/src/app/pages/Workspaces/WorkspacePackageDetails.tsx create mode 100644 workspaces/frontend/src/app/pages/Workspaces/WorkspaceStorage.tsx diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/WorkspaceDetailsActivity.cy.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/WorkspaceDetailsActivity.cy.ts index e268984d..528ca287 100644 --- a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/WorkspaceDetailsActivity.cy.ts +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/WorkspaceDetailsActivity.cy.ts @@ -11,7 +11,12 @@ describe('WorkspaceDetailsActivity Component', () => { // This tests depends on the mocked workspaces data at home page, needs revisit once workspace data fetched from BE it('open workspace details, open activity tab, check all fields match', () => { - cy.findAllByTestId('table-body').first().findByTestId('action-column').click(); + cy.findAllByTestId('table-body') + .first() + .findByTestId('action-column') + .find('button') + .should('be.visible') + .click(); // Extract first workspace from mock data cy.wait('@getWorkspaces').then((interception) => { if (!interception.response || !interception.response.body) { diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/Workspaces.cy.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/Workspaces.cy.ts index 6e401139..35a29d3e 100644 --- a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/Workspaces.cy.ts +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/Workspaces.cy.ts @@ -119,7 +119,12 @@ describe('Workspaces Component', () => { }); function openDeleteModal() { - cy.findAllByTestId('table-body').first().findByTestId('action-column').click(); + cy.findAllByTestId('table-body') + .first() + .findByTestId('action-column') + .find('button') + .should('be.visible') + .click(); cy.findByTestId('action-delete').click(); cy.findByTestId('delete-modal-input').should('have.value', ''); } 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 index 9b468b13..748f4dd7 100644 --- 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 @@ -31,20 +31,36 @@ describe('Application', () => { it('filter rows with multiple filters', () => { home.visit(); + // First filter by name useFilter('name', 'Name', 'My'); - useFilter('podConfig', 'Pod Config', 'Tiny'); - cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); + cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); + + // Add second filter by image + useFilter('image', 'Image', 'jupyter'); + cy.get("[class$='pf-v6-c-toolbar__group']").contains('Name'); + cy.get("[class$='pf-v6-c-toolbar__group']").contains('Image'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); }); it('filter rows with multiple filters and remove one', () => { home.visit(); + // Add name filter useFilter('name', 'Name', 'My'); - useFilter('podConfig', 'Pod Config', 'Tiny'); - cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); cy.get("[id$='workspaces-table-row-1']").contains('My First 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'); + + // Add image filter + useFilter('image', 'Image', 'jupyter'); + cy.get("[class$='pf-v6-c-toolbar__group']").contains('Name'); + cy.get("[class$='pf-v6-c-toolbar__group']").contains('Image'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); + + // Remove one filter (the first one) + cy.get("[class$='pf-v6-c-label-group__close']").first().click(); + cy.get("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Name'); + cy.get("[class$='pf-v6-c-toolbar__group']").contains('Image'); cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); cy.get("[id$='workspaces-table-row-2']").contains('My Second Jupyter Notebook'); @@ -52,13 +68,20 @@ describe('Application', () => { it('filter rows with multiple filters and remove all', () => { home.visit(); + // Add name filter useFilter('name', 'Name', 'My'); - useFilter('podConfig', 'Pod Config', 'Tiny'); - cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); + + // Add image filter + useFilter('image', 'Image', 'jupyter'); + cy.get("[class$='pf-v6-c-toolbar__group']").contains('Name'); + cy.get("[class$='pf-v6-c-toolbar__group']").contains('Image'); + + // Clear all filters 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("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Image'); cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); }); }); diff --git a/workspaces/frontend/src/app/components/WorkspaceTable.tsx b/workspaces/frontend/src/app/components/WorkspaceTable.tsx index a2c11866..6254ff1c 100644 --- a/workspaces/frontend/src/app/components/WorkspaceTable.tsx +++ b/workspaces/frontend/src/app/components/WorkspaceTable.tsx @@ -10,6 +10,7 @@ import { Tooltip, Bullseye, Button, + Icon, } from '@patternfly/react-core'; import { Table, @@ -36,7 +37,6 @@ import { FilterableDataFieldKey, SortableDataFieldKey, } from '~/app/filterableDataHelper'; -import { ExpandedWorkspaceRow } from '~/app/pages/Workspaces/ExpandedWorkspaceRow'; import { useTypedNavigate } from '~/app/routerHelper'; import { buildKindLogoDictionary, @@ -47,11 +47,12 @@ import { WorkspaceConnectAction } from '~/app/pages/Workspaces/WorkspaceConnectA import CustomEmptyState from '~/shared/components/CustomEmptyState'; import Filter, { FilteredColumn, FilterRef } from '~/shared/components/Filter'; import WithValidImage from '~/shared/components/WithValidImage'; +import ImageFallback from '~/shared/components/ImageFallback'; import { formatResourceFromWorkspace, formatWorkspaceIdleState, } from '~/shared/utilities/WorkspaceUtils'; -import ImageFallback from '~/shared/components/ImageFallback'; +import { ExpandedWorkspaceRow } from '~/app/pages/Workspaces/ExpandedWorkspaceRow'; const { fields: wsTableColumns, @@ -59,21 +60,16 @@ const { sortableKeyArray: sortableWsTableColumnKeyArray, filterableKeyArray: filterableWsTableColumnKeyArray, } = defineDataFields({ - redirectStatus: { label: 'Redirect Status', isFilterable: false, isSortable: false }, - name: { label: 'Name', isFilterable: true, isSortable: true }, - kind: { label: 'Kind', isFilterable: true, isSortable: true }, - namespace: { label: 'Namespace', isFilterable: true, isSortable: true }, - image: { label: 'Image', isFilterable: true, isSortable: true }, - podConfig: { label: 'Pod Config', isFilterable: true, isSortable: true }, - state: { label: 'State', isFilterable: true, isSortable: true }, - homeVol: { label: 'Home Vol', isFilterable: true, isSortable: true }, - cpu: { label: 'CPU', isFilterable: false, isSortable: true }, - ram: { label: 'Memory', isFilterable: false, isSortable: true }, - gpu: { label: 'GPU', isFilterable: true, isSortable: true }, - idleGpu: { label: 'Idle GPU', isFilterable: true, isSortable: true }, - lastActivity: { label: 'Last Activity', isFilterable: false, isSortable: true }, - connect: { label: '', isFilterable: false, isSortable: false }, - actions: { label: '', isFilterable: false, isSortable: false }, + name: { label: 'Name', isFilterable: true, isSortable: true, width: 35 }, + image: { label: 'Image', isFilterable: true, isSortable: true, width: 25 }, + kind: { label: 'Kind', isFilterable: true, isSortable: true, width: 15 }, + namespace: { label: 'Namespace', isFilterable: true, isSortable: true, width: 15 }, + state: { label: 'State', isFilterable: true, isSortable: true, width: 15 }, + gpu: { label: 'GPU', isFilterable: true, isSortable: true, width: 15 }, + idleGpu: { label: 'Idle GPU', isFilterable: true, isSortable: true, width: 15 }, + lastActivity: { label: 'Last activity', isFilterable: false, isSortable: true, width: 15 }, + connect: { label: '', isFilterable: false, isSortable: false, width: 25 }, + actions: { label: '', isFilterable: false, isSortable: false, width: 10 }, }); export type WorkspaceTableColumnKeys = DataFieldKey; @@ -202,16 +198,12 @@ const WorkspaceTable = React.forwardRef( return ws.namespace.match(searchValueInput); case 'image': return ws.podTemplate.options.imageConfig.current.displayName.match(searchValueInput); - case 'podConfig': - return ws.podTemplate.options.podConfig.current.displayName.match(searchValueInput); case 'state': return ws.state.match(searchValueInput); case 'gpu': return formatResourceFromWorkspace(ws, 'gpu').match(searchValueInput); case 'idleGpu': return formatWorkspaceIdleState(ws).match(searchValueInput); - case 'homeVol': - return ws.podTemplate.volumes.home?.mountPath.match(searchValueInput); default: return true; } @@ -228,11 +220,7 @@ const WorkspaceTable = React.forwardRef( kind: workspace.workspaceKind.name, namespace: workspace.namespace, image: workspace.podTemplate.options.imageConfig.current.displayName, - podConfig: workspace.podTemplate.options.podConfig.current.displayName, state: workspace.state, - homeVol: workspace.podTemplate.volumes.home?.pvcName ?? '', - cpu: formatResourceFromWorkspace(workspace, 'cpu'), - ram: formatResourceFromWorkspace(workspace, 'memory'), gpu: formatResourceFromWorkspace(workspace, 'gpu'), idleGpu: formatWorkspaceIdleState(workspace), lastActivity: workspace.activity.lastActivity, @@ -280,6 +268,37 @@ const WorkspaceTable = React.forwardRef( }; }; + // Column-specific modifiers and special properties + const getColumnModifier = ( + columnKey: WorkspaceTableColumnKeys, + ): 'wrap' | 'nowrap' | undefined => { + switch (columnKey) { + case 'name': + case 'kind': + return 'nowrap'; + case 'image': + case 'namespace': + case 'state': + case 'gpu': + return 'wrap'; + case 'lastActivity': + return 'nowrap'; + default: + return undefined; + } + }; + + const getSpecialColumnProps = (columnKey: WorkspaceTableColumnKeys) => { + switch (columnKey) { + case 'connect': + return { screenReaderText: 'Connect action', hasContent: false }; + case 'actions': + return { screenReaderText: 'Primary action', hasContent: false }; + default: + return { hasContent: true }; + } + }; + const extractStateColor = (state: WorkspaceState) => { switch (state) { case WorkspaceState.WorkspaceStateRunning: @@ -305,31 +324,41 @@ const WorkspaceTable = React.forwardRef( case 'Info': return ( - ); case 'Warning': return ( - ); case 'Danger': return ( - ); case undefined: return ( - ); default: return ( - ); } @@ -371,19 +400,32 @@ const WorkspaceTable = React.forwardRef( } /> - +
- {canExpandRows && - ))} + {canExpandRows && + ); + })} {sortedWorkspaces.length > 0 && @@ -397,6 +439,7 @@ const WorkspaceTable = React.forwardRef( {canExpandRows && ( + ); + } + + if (columnKey === 'actions') { + return ( + + ); + } + + return ( + - ); - case 'name': - return ( - - ); - case 'kind': - return ( - - ); - case 'namespace': - return ( - - ); - case 'image': - return ( - - ); - case 'podConfig': - return ( - - ); - case 'state': - return ( - - ); - case 'homeVol': - return ( - - ); - case 'cpu': - return ( - - ); - case 'ram': - return ( - - ); - case 'gpu': - return ( - - ); - case 'idleGpu': - return ( - - ); - case 'lastActivity': - return ( - - ); - case 'connect': - return ( - - ); - case 'actions': - return ( - - ); - default: - return null; - } + {formatDistanceToNow(new Date(workspace.activity.lastActivity), { + addSuffix: true, + })} + + )} + + ); })} {isWorkspaceExpanded(workspace) && ( - + )} ))} diff --git a/workspaces/frontend/src/app/filterableDataHelper.ts b/workspaces/frontend/src/app/filterableDataHelper.ts index 5f3c3704..ed7c5891 100644 --- a/workspaces/frontend/src/app/filterableDataHelper.ts +++ b/workspaces/frontend/src/app/filterableDataHelper.ts @@ -2,6 +2,7 @@ export interface DataFieldDefinition { label: string; isSortable: boolean; isFilterable: boolean; + width?: number; } export type FilterableDataFieldKey> = { diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/summary/WorkspaceKindSummary.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/summary/WorkspaceKindSummary.tsx index 164fee2c..f09d1102 100644 --- a/workspaces/frontend/src/app/pages/WorkspaceKinds/summary/WorkspaceKindSummary.tsx +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/summary/WorkspaceKindSummary.tsx @@ -92,7 +92,7 @@ const WorkspaceKindSummary: React.FC = () => { ref={workspaceTableRef} workspaces={workspaces} canCreateWorkspaces={false} - hiddenColumns={['connect', 'kind', 'homeVol']} + hiddenColumns={['connect', 'kind']} rowActions={tableRowActions} /> diff --git a/workspaces/frontend/src/app/pages/Workspaces/DataVolumesList.tsx b/workspaces/frontend/src/app/pages/Workspaces/DataVolumesList.tsx index 80f4e3c4..9c2a87bf 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/DataVolumesList.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/DataVolumesList.tsx @@ -3,12 +3,14 @@ import { ClipboardCopy, ClipboardCopyVariant, Content, + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, Flex, FlexItem, List, ListItem, - Stack, - StackItem, Tooltip, } from '@patternfly/react-core'; import { DatabaseIcon, LockedIcon } from '@patternfly/react-icons'; @@ -43,32 +45,32 @@ export const DataVolumesList: React.FC = ({ workspace }) = )} - - - - Mount path:{' '} - - {data.mountPath} - - - - + + Mount path: + + + {data.mountPath} + + + ); return ( - - - Cluster storage - - - - {workspaceDataVol.map((data, index) => ( - {singleDataVolRenderer(data)} - ))} - - - + + + + Cluster storage + + + + {workspaceDataVol.map((data, index) => ( + {singleDataVolRenderer(data)} + ))} + + + + ); }; diff --git a/workspaces/frontend/src/app/pages/Workspaces/ExpandedWorkspaceRow.tsx b/workspaces/frontend/src/app/pages/Workspaces/ExpandedWorkspaceRow.tsx index 4d53caf8..bb122c4f 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/ExpandedWorkspaceRow.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/ExpandedWorkspaceRow.tsx @@ -1,38 +1,74 @@ import React from 'react'; -import { ExpandableRowContent, Td, Tr } from '@patternfly/react-table'; +import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table'; import { Workspace } from '~/shared/api/backendApiTypes'; -import { DataVolumesList } from '~/app/pages/Workspaces/DataVolumesList'; import { WorkspaceTableColumnKeys } from '~/app/components/WorkspaceTable'; +import { WorkspaceStorage } from './WorkspaceStorage'; +import { WorkspacePackageDetails } from './WorkspacePackageDetails'; +import { WorkspaceConfigDetails } from './WorkspaceConfigDetails'; interface ExpandedWorkspaceRowProps { workspace: Workspace; - columnKeys: WorkspaceTableColumnKeys[]; + visibleColumnKeys: WorkspaceTableColumnKeys[]; + canExpandRows: boolean; } export const ExpandedWorkspaceRow: React.FC = ({ workspace, - columnKeys, + visibleColumnKeys, + canExpandRows, }) => { - const renderExpandedData = () => - columnKeys.map((colKey, index) => { - switch (colKey) { - case 'name': + // Calculate total number of columns (including expand column if present) + const totalColumns = visibleColumnKeys.length + (canExpandRows ? 1 : 0); + + // Find the positions where we want to show our content + // We'll show storage in the first content column, package details in the second, + // and config details in the third + const getColumnIndex = (columnKey: WorkspaceTableColumnKeys) => { + const baseIndex = canExpandRows ? 1 : 0; // Account for expand column + return baseIndex + visibleColumnKeys.indexOf(columnKey); + }; + + const storageColumnIndex = visibleColumnKeys.includes('name') ? getColumnIndex('name') : 1; + const packageColumnIndex = visibleColumnKeys.includes('image') ? getColumnIndex('image') : 2; + const configColumnIndex = visibleColumnKeys.includes('kind') ? getColumnIndex('kind') : 3; + + return ( + + {/* Render cells for each column */} + {Array.from({ length: totalColumns }, (_, index) => { + if (index === storageColumnIndex) { return ( - ); - default: - return - + ); + } + + if (index === configColumnIndex) { + return ( + + ); + } + + // Empty cell for all other columns + return ); }; diff --git a/workspaces/frontend/src/app/pages/Workspaces/WorkspaceConfigDetails.tsx b/workspaces/frontend/src/app/pages/Workspaces/WorkspaceConfigDetails.tsx new file mode 100644 index 00000000..b59873e2 --- /dev/null +++ b/workspaces/frontend/src/app/pages/Workspaces/WorkspaceConfigDetails.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { + DescriptionList, + DescriptionListTerm, + DescriptionListGroup, + DescriptionListDescription, +} from '@patternfly/react-core'; +import { Workspace } from '~/shared/api/backendApiTypes'; +import { formatResourceFromWorkspace } from '~/shared/utilities/WorkspaceUtils'; + +interface WorkspaceConfigDetailsProps { + workspace: Workspace; +} + +export const WorkspaceConfigDetails: React.FC = ({ workspace }) => ( + + + Pod config + + {workspace.podTemplate.options.podConfig.current.displayName} + + + + CPU + + {formatResourceFromWorkspace(workspace, 'cpu')} + + + + Memory + + {formatResourceFromWorkspace(workspace, 'memory')} + + + +); diff --git a/workspaces/frontend/src/app/pages/Workspaces/WorkspaceConnectAction.tsx b/workspaces/frontend/src/app/pages/Workspaces/WorkspaceConnectAction.tsx index ab2decb2..efc59fe7 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/WorkspaceConnectAction.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/WorkspaceConnectAction.tsx @@ -52,6 +52,7 @@ export const WorkspaceConnectAction: React.FunctionComponent) => ( Connect , diff --git a/workspaces/frontend/src/app/pages/Workspaces/WorkspacePackageDetails.tsx b/workspaces/frontend/src/app/pages/Workspaces/WorkspacePackageDetails.tsx new file mode 100644 index 00000000..1eb96fee --- /dev/null +++ b/workspaces/frontend/src/app/pages/Workspaces/WorkspacePackageDetails.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { + DescriptionList, + DescriptionListTerm, + DescriptionListDescription, + ListItem, + List, + DescriptionListGroup, +} from '@patternfly/react-core'; +import { Workspace } from '~/shared/api/backendApiTypes'; +import { extractPackageLabels, formatLabelKey } from '~/shared/utilities/WorkspaceUtils'; + +interface WorkspacePackageDetailsProps { + workspace: Workspace; +} + +export const WorkspacePackageDetails: React.FC = ({ workspace }) => { + const packageLabels = extractPackageLabels(workspace); + + const renderedItems = packageLabels.map((label) => ( + {`${formatLabelKey(label.key)} v${label.value}`} + )); + + return ( + + + Packages + + {renderedItems.length > 0 ? ( + {renderedItems} + ) : ( + No package information available + )} + + + + ); +}; diff --git a/workspaces/frontend/src/app/pages/Workspaces/WorkspaceStorage.tsx b/workspaces/frontend/src/app/pages/Workspaces/WorkspaceStorage.tsx new file mode 100644 index 00000000..9109bdf0 --- /dev/null +++ b/workspaces/frontend/src/app/pages/Workspaces/WorkspaceStorage.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { + DescriptionList, + DescriptionListTerm, + DescriptionListGroup, + DescriptionListDescription, +} from '@patternfly/react-core'; +import { Workspace } from '~/shared/api/backendApiTypes'; +import { DataVolumesList } from '~/app/pages/Workspaces/DataVolumesList'; + +interface WorkspaceStorageProps { + workspace: Workspace; +} + +export const WorkspaceStorage: React.FC = ({ workspace }) => ( + + + Home volume + + {workspace.podTemplate.volumes.home?.pvcName ?? 'None'} + + + + + + +); diff --git a/workspaces/frontend/src/shared/mock/mockBuilder.ts b/workspaces/frontend/src/shared/mock/mockBuilder.ts index 002d1279..387c5387 100644 --- a/workspaces/frontend/src/shared/mock/mockBuilder.ts +++ b/workspaces/frontend/src/shared/mock/mockBuilder.ts @@ -81,6 +81,10 @@ export const buildMockWorkspace = (workspace?: Partial): Workspace => key: 'pythonVersion', value: '3.11', }, + { + key: 'jupyterlabVersion', + value: '1.9.0', + }, ], }, }, @@ -290,9 +294,30 @@ export const buildMockWorkspaceList = (args: { }): Workspace[] => { const states = Object.values(WorkspaceState); const imageConfigs = [ - { id: 'jupyterlab_scipy_190', displayName: `jupyter-scipy:v1.9.0` }, - { id: 'jupyterlab_scipy_200', displayName: `jupyter-scipy:v2.0.0` }, - { id: 'jupyterlab_scipy_210', displayName: `jupyter-scipy:v2.1.0` }, + { + id: 'jupyterlab_scipy_190', + displayName: `jupyter-scipy:v1.9.0`, + labels: [ + { key: 'pythonVersion', value: '3.12' }, + { key: 'jupyterlabVersion', value: '1.9.0' }, + ], + }, + { + id: 'jupyterlab_scipy_200', + displayName: `jupyter-scipy:v2.0.0`, + labels: [ + { key: 'pythonVersion', value: '3.12' }, + { key: 'jupyterlabVersion', value: '2.0.0' }, + ], + }, + { + id: 'jupyterlab_scipy_210', + displayName: `jupyter-scipy:v2.1.0`, + labels: [ + { key: 'pythonVersion', value: '3.13' }, + { key: 'jupyterlabVersion', value: '2.1.0' }, + ], + }, ]; const podConfigs = [ { id: 'tiny_cpu', displayName: 'Tiny CPU' }, @@ -353,12 +378,7 @@ export const buildMockWorkspaceList = (args: { id: imageConfig.id, displayName: imageConfig.displayName, description: 'JupyterLab, with SciPy Packages', - labels: [ - { - key: 'pythonVersion', - value: '3.11', - }, - ], + labels: imageConfig.labels, }, }, podConfig: { diff --git a/workspaces/frontend/src/shared/style/MUI-theme.scss b/workspaces/frontend/src/shared/style/MUI-theme.scss index ec9aa157..8c76d450 100644 --- a/workspaces/frontend/src/shared/style/MUI-theme.scss +++ b/workspaces/frontend/src/shared/style/MUI-theme.scss @@ -15,6 +15,7 @@ // Button --mui-button-font-weight: 500; + --mui-button--BorderWidth: 1px; --mui-button--hover--BorderWidth: 1px; --mui-button--PaddingBlockStart: 6px; --mui-button--PaddingBlockEnd: 6px; @@ -76,7 +77,7 @@ --mui-table--cell--PaddingInlineEnd: 16px; --mui-table--cell--PaddingBlockStart: 16px; --mui-table--cell--PaddingBlockEnd: 16px; - --mui-table--cell--first-last-child--PaddingInline: 0px; + --mui-table--cell--first-last-child--PaddingInline: 8px; --mui-table__thead--cell--FontSize: 14px; --mui-table__sort-indicator--MarginInlineStart: 4px; @@ -116,7 +117,6 @@ } .mui-theme .pf-v6-c-action-list__item .pf-v6-c-button { - --pf-v6-c-button--BorderRadius: 50%; --pf-v6-c-button--PaddingInlineStart: none; --pf-v6-c-button--PaddingInlineEnd: none; } @@ -153,7 +153,6 @@ --pf-v6-c-button--PaddingInlineStart: var(--mui-button--PaddingInlineStart); --pf-v6-c-button--PaddingInlineEnd: var(--mui-button--PaddingInlineEnd); --pf-v6-c-button--LineHeight: var(--mui-button--LineHeight); - --pf-v6-c-button--m-plain--BorderRadius: 50%; text-transform: var(--mui-text-transform); letter-spacing: 0.02857em; @@ -198,10 +197,6 @@ --pf-v6-c-card--m-selectable--m-selected--BorderColor: none; } -.mui-theme .pf-v6-c-description-list { - --pf-v6-c-description-list--RowGap: var(--pf-t--global--spacer--gap--group-to-group--horizontal--compact); -} - .mui-theme .pf-v6-c-description-list__term { font: var(--mui-font-subtitle2); } @@ -238,20 +233,26 @@ --pf-v6-c-form__section-title--MarginInlineEnd: 0px; } +// Base form label styles .mui-theme .pf-v6-c-form__label { + color: var(--mui-palette-grey-600); + pointer-events: none; + transition: all 0.2s ease; + --pf-v6-c-form__label-required--Color: currentColor; +} + +// Text input labels (text fields, textareas, selects) +.mui-theme .pf-v6-c-form__group:has(.pf-v6-c-form-control) .pf-v6-c-form__label, +.mui-theme .pf-v6-c-form__group:has(.pf-v6-c-text-input-group) .pf-v6-c-form__label { position: absolute; top: 35%; left: 12px; font-size: 14px; - color: var(--mui-palette-grey-600); - pointer-events: none; - transition: all 0.2s ease; transform-origin: left center; transform: translateY(-50%) scale(0.75); background-color: var(--mui-palette-common-white); padding: 0 4px; z-index: 1; - --pf-v6-c-form__label-required--Color: currentColor; } .mui-theme .pf-v6-c-form-control input::placeholder { @@ -499,10 +500,6 @@ align-self: stretch; } -.mui-theme .pf-v6-c-menu-toggle.pf-m-plain { - --pf-v6-c-menu-toggle--BorderRadius: 50%; -} - .mui-theme .pf-v6-c-menu-toggle.pf-m-primary { --pf-v6-c-menu-toggle--expanded--Color: var(--mui-palette-common-white); --pf-v6-c-menu-toggle--expanded--BackgroundColor: var(--mui-palette-primary-main); @@ -517,6 +514,13 @@ --pf-v6-c-menu-toggle--m-split-button--m-action--m-primary--child--BackgroundColor: var(--mui-palette-primary-dark); } +.pf-v6-c-menu-toggle.pf-m-secondary.pf-m-split-button { + --pf-v6-c-menu-toggle--BorderColor: var(--mui-palette-primary-main); + --pf-v6-c-menu-toggle--BorderWidth: var(--mui-button--BorderWidth); + --pf-v6-c-menu-toggle--BorderStyle: solid; + --pf-v6-c-menu-toggle--expanded--Color: var(--mui-palette-primary-dark); +} + .mui-theme .pf-v6-c-menu-toggle__button:has(.pf-v6-c-menu-toggle__toggle-icon) { --pf-v6-c-menu-toggle--PaddingBlockStart: var(--mui-spacing-4); --pf-v6-c-menu-toggle--PaddingBlockEnd: var(--mui-spacing-4); @@ -710,6 +714,12 @@ align-content: center; } +// Updates the expand button to be 36.5px wide to match menu toggle button width +.mui-theme .pf-v6-c-table__td.pf-v6-c-table__toggle .pf-v6-c-button.pf-m-plain { + --pf-v6-c-button--PaddingInlineStart: 6px; + --pf-v6-c-button--PaddingInlineEnd: 6px; +} + .mui-theme .pf-v6-c-label { --pf-v6-c-label--BorderRadius: 16px; --pf-v6-c-label--FontSize: 0.8125rem; @@ -760,10 +770,6 @@ --pf-v6-c-modal-box--BoxShadow: var(--mui-shadows-24); } -.mui-theme .pf-v6-c-button.pf-m-plain { - --pf-v6-c-button--BorderRadius: 50%; -} - .mui-theme .pf-v6-c-page__main-container { --pf-v6-c-page__main-container--BorderWidth: 0px; --pf-v6-c-page__main-container--BorderRadius: var(--mui-shape-borderRadius); diff --git a/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts b/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts index a732b4af..b554cb9c 100644 --- a/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts +++ b/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts @@ -1,4 +1,4 @@ -import { Workspace, WorkspaceState } from '~/shared/api/backendApiTypes'; +import { Workspace, WorkspaceState, WorkspaceOptionLabel } from '~/shared/api/backendApiTypes'; import { CPU_UNITS, MEMORY_UNITS_FOR_PARSING, @@ -93,3 +93,24 @@ export const countGpusFromWorkspaces = (workspaces: Workspace[]): number => const [gpuValue] = splitValueUnit(extractResourceValue(workspace, 'gpu') || '0', OTHER); return total + (gpuValue ?? 0); }, 0); + +// Helper function to format label keys into human-readable names +export const formatLabelKey = (key: string): string => { + // Handle camelCase version labels (e.g., pythonVersion -> Python) + if (key.endsWith('Version')) { + const baseName = key.slice(0, -7); // Remove 'Version' suffix + return baseName.charAt(0).toUpperCase() + baseName.slice(1); + } + + // Otherwise just capitalize the first letter + return key.charAt(0).toUpperCase() + key.slice(1); +}; + +// Check if a label represents version/package information +export const isPackageLabel = (key: string): boolean => key.endsWith('Version'); + +// Extract package labels from workspace image config +export const extractPackageLabels = (workspace: Workspace): WorkspaceOptionLabel[] => + workspace.podTemplate.options.imageConfig.current.labels.filter((label) => + isPackageLabel(label.key), + );
} - {visibleColumnKeys.map((columnKey) => ( - - {wsTableColumns[columnKey].label} - } + {visibleColumnKeys.map((columnKey) => { + const specialProps = getSpecialColumnProps(columnKey); + const modifier = getColumnModifier(columnKey); + + return ( + + {specialProps.hasContent ? wsTableColumns[columnKey].label : undefined} +
( /> )} {visibleColumnKeys.map((columnKey) => { - switch (columnKey) { - case 'redirectStatus': - return ( - + if (columnKey === 'connect') { + return ( + + + + ({ + ...action, + 'data-testid': `action-${action.id || ''}`, + }))} + /> + + {columnKey === 'name' && workspace.name} + {columnKey === 'image' && ( + + {workspace.podTemplate.options.imageConfig.current.displayName}{' '} {workspaceRedirectStatus[workspace.workspaceKind.name] ? getRedirectStatusIcon( workspaceRedirectStatus[workspace.workspaceKind.name]?.message @@ -421,143 +497,57 @@ const WorkspaceTable = React.forwardRef( ?.text || 'No API response available', ) : getRedirectStatusIcon(undefined, 'No API response available')} - + )} + {columnKey === 'kind' && ( + + } > - {workspace.name} - - ( + + {workspace.workspaceKind.name} - } - > - {(validSrc) => ( - - {workspace.workspaceKind.name} - - )} - - - {workspace.namespace} - - {workspace.podTemplate.options.imageConfig.current.displayName} - + )} + + )} + {columnKey === 'namespace' && workspace.namespace} + {columnKey === 'state' && ( + + )} + {columnKey === 'gpu' && formatResourceFromWorkspace(workspace, 'gpu')} + {columnKey === 'idleGpu' && formatWorkspaceIdleState(workspace)} + {columnKey === 'lastActivity' && ( + - {workspace.podTemplate.options.podConfig.current.displayName} - - - - {workspace.podTemplate.volumes.home?.pvcName ?? ''} - - {formatResourceFromWorkspace(workspace, 'cpu')} - - {formatResourceFromWorkspace(workspace, 'memory')} - - {formatResourceFromWorkspace(workspace, 'gpu')} - - {formatWorkspaceIdleState(workspace)} - - - {formatDistanceToNow(new Date(workspace.activity.lastActivity), { - addSuffix: true, - })} - - - - - ({ - ...action, - 'data-testid': `action-${action.id || ''}`, - }))} - /> -
+ - + ; - } - }); + } - return ( -
- {renderExpandedData()} + if (index === packageColumnIndex) { + return ( + + + + + + + + + ; + })}