fix(ws): Updates to Table Columns, Expandable Rows, and Theming (#432)

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
This commit is contained in:
Jenny 2025-07-07 15:09:50 -04:00 committed by Bhakti Narvekar
parent da615f5f7e
commit 9607fabd93
15 changed files with 471 additions and 259 deletions

View File

@ -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) {

View File

@ -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', '');
}

View File

@ -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);
});
});

View File

@ -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<typeof wsTableColumns>;
@ -202,16 +198,12 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
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<WorkspaceTableRef, WorkspaceTableProps>(
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<WorkspaceTableRef, WorkspaceTableProps>(
};
};
// 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<WorkspaceTableRef, WorkspaceTableProps>(
case 'Info':
return (
<Tooltip content={message}>
<InfoCircleIcon color="blue" aria-hidden="true" />
<Icon status="info" isInline>
<InfoCircleIcon aria-hidden="true" />
</Icon>
</Tooltip>
);
case 'Warning':
return (
<Tooltip content={message}>
<ExclamationTriangleIcon color="orange" aria-hidden="true" />
<Icon isInline>
<ExclamationTriangleIcon color="orange" aria-hidden="true" />
</Icon>
</Tooltip>
);
case 'Danger':
return (
<Tooltip content={message}>
<TimesCircleIcon color="red" aria-hidden="true" />
<Icon isInline>
<TimesCircleIcon color="red" aria-hidden="true" />
</Icon>
</Tooltip>
);
case undefined:
return (
<Tooltip content={message}>
<QuestionCircleIcon color="gray" aria-hidden="true" />
<Icon isInline>
<QuestionCircleIcon color="gray" aria-hidden="true" />
</Icon>
</Tooltip>
);
default:
return (
<Tooltip content={`Invalid level: ${level}`}>
<QuestionCircleIcon color="gray" aria-hidden="true" />
<Icon isInline>
<QuestionCircleIcon color="gray" aria-hidden="true" />
</Icon>
</Tooltip>
);
}
@ -371,19 +400,32 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
}
/>
</Content>
<Table data-testid="workspaces-table" aria-label="Sortable table" ouiaId="SortableTable">
<Table
data-testid="workspaces-table"
aria-label="Sortable table"
ouiaId="SortableTable"
style={{ tableLayout: 'fixed' }}
>
<Thead>
<Tr>
{canExpandRows && <Th screenReaderText="expand-action" />}
{visibleColumnKeys.map((columnKey) => (
<Th
key={`workspace-table-column-${columnKey}`}
sort={getSortParams(columnKey)}
aria-label={columnKey}
>
{wsTableColumns[columnKey].label}
</Th>
))}
{canExpandRows && <Th width={10} screenReaderText="expand-action" />}
{visibleColumnKeys.map((columnKey) => {
const specialProps = getSpecialColumnProps(columnKey);
const modifier = getColumnModifier(columnKey);
return (
<Th
width={wsTableColumns[columnKey].width}
key={`workspace-table-column-${columnKey}`}
sort={specialProps.hasContent ? getSortParams(columnKey) : undefined}
aria-label={specialProps.hasContent ? columnKey : undefined}
modifier={modifier}
screenReaderText={specialProps.screenReaderText}
>
{specialProps.hasContent ? wsTableColumns[columnKey].label : undefined}
</Th>
);
})}
</Tr>
</Thead>
{sortedWorkspaces.length > 0 &&
@ -397,6 +439,7 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
<Tr
id={`workspaces-table-row-${rowIndex + 1}`}
data-testid={`workspace-row-${rowIndex}`}
isStriped={rowIndex % 2 === 0}
>
{canExpandRows && (
<Td
@ -409,10 +452,43 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
/>
)}
{visibleColumnKeys.map((columnKey) => {
switch (columnKey) {
case 'redirectStatus':
return (
<Td key={columnKey} dataLabel={wsTableColumns[columnKey].label}>
if (columnKey === 'connect') {
return (
<Td key="connect" isActionCell>
<WorkspaceConnectAction workspace={workspace} />
</Td>
);
}
if (columnKey === 'actions') {
return (
<Td key="actions" isActionCell data-testid="action-column">
<ActionsColumn
items={rowActions(workspace).map((action) => ({
...action,
'data-testid': `action-${action.id || ''}`,
}))}
/>
</Td>
);
}
return (
<Td
key={columnKey}
data-testid={
columnKey === 'name'
? 'workspace-name'
: columnKey === 'state'
? 'state-label'
: `workspace-${columnKey}`
}
dataLabel={wsTableColumns[columnKey].label}
>
{columnKey === 'name' && workspace.name}
{columnKey === 'image' && (
<Content>
{workspace.podTemplate.options.imageConfig.current.displayName}{' '}
{workspaceRedirectStatus[workspace.workspaceKind.name]
? getRedirectStatusIcon(
workspaceRedirectStatus[workspace.workspaceKind.name]?.message
@ -421,143 +497,57 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
?.text || 'No API response available',
)
: getRedirectStatusIcon(undefined, 'No API response available')}
</Td>
);
case 'name':
return (
<Td
key={columnKey}
data-testid="workspace-name"
dataLabel={wsTableColumns[columnKey].label}
</Content>
)}
{columnKey === 'kind' && (
<WithValidImage
imageSrc={kindLogoDict[workspace.workspaceKind.name]}
skeletonWidth="20px"
fallback={
<ImageFallback
imageSrc={kindLogoDict[workspace.workspaceKind.name]}
/>
}
>
{workspace.name}
</Td>
);
case 'kind':
return (
<Td key={columnKey} dataLabel={wsTableColumns[columnKey].label}>
<WithValidImage
imageSrc={kindLogoDict[workspace.workspaceKind.name]}
skeletonWidth="20px"
fallback={
<ImageFallback
imageSrc={kindLogoDict[workspace.workspaceKind.name]}
{(validSrc) => (
<Tooltip content={workspace.workspaceKind.name}>
<img
src={validSrc}
alt={workspace.workspaceKind.name}
style={{ width: '20px', height: '20px', cursor: 'pointer' }}
/>
}
>
{(validSrc) => (
<Tooltip content={workspace.workspaceKind.name}>
<img
src={validSrc}
alt={workspace.workspaceKind.name}
style={{ width: '20px', height: '20px', cursor: 'pointer' }}
/>
</Tooltip>
)}
</WithValidImage>
</Td>
);
case 'namespace':
return (
<Td key={columnKey} dataLabel={wsTableColumns[columnKey].label}>
{workspace.namespace}
</Td>
);
case 'image':
return (
<Td key={columnKey} dataLabel={wsTableColumns[columnKey].label}>
{workspace.podTemplate.options.imageConfig.current.displayName}
</Td>
);
case 'podConfig':
return (
<Td
key={columnKey}
data-testid="pod-config"
dataLabel={wsTableColumns[columnKey].label}
</Tooltip>
)}
</WithValidImage>
)}
{columnKey === 'namespace' && workspace.namespace}
{columnKey === 'state' && (
<Label color={extractStateColor(workspace.state)}>
{workspace.state}
</Label>
)}
{columnKey === 'gpu' && formatResourceFromWorkspace(workspace, 'gpu')}
{columnKey === 'idleGpu' && formatWorkspaceIdleState(workspace)}
{columnKey === 'lastActivity' && (
<Timestamp
date={new Date(workspace.activity.lastActivity)}
tooltip={{ variant: TimestampTooltipVariant.default }}
>
{workspace.podTemplate.options.podConfig.current.displayName}
</Td>
);
case 'state':
return (
<Td
key={columnKey}
data-testid="state-label"
dataLabel={wsTableColumns[columnKey].label}
>
<Label color={extractStateColor(workspace.state)}>
{workspace.state}
</Label>
</Td>
);
case 'homeVol':
return (
<Td key={columnKey} dataLabel={wsTableColumns[columnKey].label}>
{workspace.podTemplate.volumes.home?.pvcName ?? ''}
</Td>
);
case 'cpu':
return (
<Td key={columnKey} dataLabel={wsTableColumns[columnKey].label}>
{formatResourceFromWorkspace(workspace, 'cpu')}
</Td>
);
case 'ram':
return (
<Td key={columnKey} dataLabel={wsTableColumns[columnKey].label}>
{formatResourceFromWorkspace(workspace, 'memory')}
</Td>
);
case 'gpu':
return (
<Td key={columnKey} dataLabel={wsTableColumns[columnKey].label}>
{formatResourceFromWorkspace(workspace, 'gpu')}
</Td>
);
case 'idleGpu':
return (
<Td key={columnKey} dataLabel={wsTableColumns[columnKey].label}>
{formatWorkspaceIdleState(workspace)}
</Td>
);
case 'lastActivity':
return (
<Td key={columnKey} dataLabel={wsTableColumns[columnKey].label}>
<Timestamp
date={new Date(workspace.activity.lastActivity)}
tooltip={{ variant: TimestampTooltipVariant.default }}
>
{formatDistanceToNow(new Date(workspace.activity.lastActivity), {
addSuffix: true,
})}
</Timestamp>
</Td>
);
case 'connect':
return (
<Td key={columnKey} isActionCell>
<WorkspaceConnectAction workspace={workspace} />
</Td>
);
case 'actions':
return (
<Td key={columnKey} isActionCell data-testid="action-column">
<ActionsColumn
items={rowActions(workspace).map((action) => ({
...action,
'data-testid': `action-${action.id || ''}`,
}))}
/>
</Td>
);
default:
return null;
}
{formatDistanceToNow(new Date(workspace.activity.lastActivity), {
addSuffix: true,
})}
</Timestamp>
)}
</Td>
);
})}
</Tr>
{isWorkspaceExpanded(workspace) && (
<ExpandedWorkspaceRow workspace={workspace} columnKeys={visibleColumnKeys} />
<ExpandedWorkspaceRow
workspace={workspace}
visibleColumnKeys={visibleColumnKeys}
canExpandRows={canExpandRows}
/>
)}
</Tbody>
))}

View File

@ -2,6 +2,7 @@ export interface DataFieldDefinition {
label: string;
isSortable: boolean;
isFilterable: boolean;
width?: number;
}
export type FilterableDataFieldKey<T extends Record<string, DataFieldDefinition>> = {

View File

@ -92,7 +92,7 @@ const WorkspaceKindSummary: React.FC = () => {
ref={workspaceTableRef}
workspaces={workspaces}
canCreateWorkspaces={false}
hiddenColumns={['connect', 'kind', 'homeVol']}
hiddenColumns={['connect', 'kind']}
rowActions={tableRowActions}
/>
</StackItem>

View File

@ -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<DataVolumesListProps> = ({ workspace }) =
</Tooltip>
)}
</Content>
<Stack>
<StackItem>
<Content component="small">
Mount path:{' '}
<ClipboardCopy variant={ClipboardCopyVariant.inlineCompact}>
{data.mountPath}
</ClipboardCopy>
</Content>
</StackItem>
</Stack>
<Flex gap={{ default: 'gapSm' }} flexWrap={{ default: 'wrap' }}>
<FlexItem>Mount path:</FlexItem>
<FlexItem>
<ClipboardCopy variant={ClipboardCopyVariant.inlineCompact} isCode>
{data.mountPath}
</ClipboardCopy>
</FlexItem>
</Flex>
</FlexItem>
</Flex>
);
return (
<Stack hasGutter>
<StackItem>
<strong data-testid="notebook-storage-bar-title">Cluster storage</strong>
</StackItem>
<StackItem>
<List isPlain>
{workspaceDataVol.map((data, index) => (
<ListItem key={`data-vol-${index}`}>{singleDataVolRenderer(data)}</ListItem>
))}
</List>
</StackItem>
</Stack>
<DescriptionList>
<DescriptionListGroup>
<DescriptionListTerm data-testid="notebook-storage-bar-title">
Cluster storage
</DescriptionListTerm>
<DescriptionListDescription>
<List isPlain>
{workspaceDataVol.map((data, index) => (
<ListItem key={`data-vol-${index}`}>{singleDataVolRenderer(data)}</ListItem>
))}
</List>
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
);
};

View File

@ -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<ExpandedWorkspaceRowProps> = ({
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 (
<Tr isExpanded>
{/* Render cells for each column */}
{Array.from({ length: totalColumns }, (_, index) => {
if (index === storageColumnIndex) {
return (
<Td noPadding colSpan={1} key={colKey}>
<Td key={`storage-${index}`} dataLabel="Storage" modifier="nowrap">
<ExpandableRowContent>
<DataVolumesList workspace={workspace} />
<WorkspaceStorage workspace={workspace} />
</ExpandableRowContent>
</Td>
);
default:
return <Td key={index} />;
}
});
}
return (
<Tr>
<Td />
{renderExpandedData()}
if (index === packageColumnIndex) {
return (
<Td key={`package-${index}`} modifier="nowrap">
<ExpandableRowContent>
<WorkspacePackageDetails workspace={workspace} />
</ExpandableRowContent>
</Td>
);
}
if (index === configColumnIndex) {
return (
<Td key={`config-${index}`} modifier="nowrap">
<ExpandableRowContent>
<WorkspaceConfigDetails workspace={workspace} />
</ExpandableRowContent>
</Td>
);
}
// Empty cell for all other columns
return <Td key={`empty-${index}`} />;
})}
</Tr>
);
};

View File

@ -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<WorkspaceConfigDetailsProps> = ({ workspace }) => (
<DescriptionList>
<DescriptionListGroup>
<DescriptionListTerm>Pod config</DescriptionListTerm>
<DescriptionListDescription>
{workspace.podTemplate.options.podConfig.current.displayName}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>CPU</DescriptionListTerm>
<DescriptionListDescription>
{formatResourceFromWorkspace(workspace, 'cpu')}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>Memory</DescriptionListTerm>
<DescriptionListDescription>
{formatResourceFromWorkspace(workspace, 'memory')}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
);

View File

@ -52,6 +52,7 @@ export const WorkspaceConnectAction: React.FunctionComponent<WorkspaceConnectAct
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
variant="secondary"
onClick={onToggleClick}
isExpanded={open}
isDisabled={workspace.state !== WorkspaceState.WorkspaceStateRunning}
@ -60,6 +61,7 @@ export const WorkspaceConnectAction: React.FunctionComponent<WorkspaceConnectAct
id="connect-endpoint-button"
key="connect-endpoint-button"
onClick={onClickConnect}
className="connect-button-no-wrap"
>
Connect
</MenuToggleAction>,

View File

@ -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<WorkspacePackageDetailsProps> = ({ workspace }) => {
const packageLabels = extractPackageLabels(workspace);
const renderedItems = packageLabels.map((label) => (
<ListItem key={label.key}>{`${formatLabelKey(label.key)} v${label.value}`}</ListItem>
));
return (
<DescriptionList>
<DescriptionListGroup>
<DescriptionListTerm>Packages</DescriptionListTerm>
<DescriptionListDescription>
{renderedItems.length > 0 ? (
<List isPlain>{renderedItems}</List>
) : (
<span>No package information available</span>
)}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
);
};

View File

@ -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<WorkspaceStorageProps> = ({ workspace }) => (
<DescriptionList>
<DescriptionListGroup>
<DescriptionListTerm>Home volume</DescriptionListTerm>
<DescriptionListDescription>
{workspace.podTemplate.volumes.home?.pvcName ?? 'None'}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DataVolumesList workspace={workspace} />
</DescriptionListGroup>
</DescriptionList>
);

View File

@ -81,6 +81,10 @@ export const buildMockWorkspace = (workspace?: Partial<Workspace>): 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: {

View File

@ -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;
}
@ -152,7 +152,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;
@ -197,10 +196,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);
}
@ -237,20 +232,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 {
@ -514,10 +515,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);
@ -532,6 +529,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);
@ -725,6 +729,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;
@ -775,10 +785,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);

View File

@ -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,
@ -102,3 +102,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),
);