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
This commit is contained in:
Jenny 2025-06-04 14:53:11 -04:00
parent ca8e94c5c1
commit 7fa255c027
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 // 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', () => { 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 // Extract first workspace from mock data
cy.wait('@getWorkspaces').then((interception) => { cy.wait('@getWorkspaces').then((interception) => {
if (!interception.response || !interception.response.body) { if (!interception.response || !interception.response.body) {

View File

@ -119,7 +119,12 @@ describe('Workspaces Component', () => {
}); });
function openDeleteModal() { 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('action-delete').click();
cy.findByTestId('delete-modal-input').should('have.value', ''); cy.findByTestId('delete-modal-input').should('have.value', '');
} }

View File

@ -31,20 +31,36 @@ describe('Application', () => {
it('filter rows with multiple filters', () => { it('filter rows with multiple filters', () => {
home.visit(); home.visit();
// First filter by name
useFilter('name', 'Name', 'My'); useFilter('name', 'Name', 'My');
useFilter('podConfig', 'Pod Config', 'Tiny'); cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2);
cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); 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'); cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook');
}); });
it('filter rows with multiple filters and remove one', () => { it('filter rows with multiple filters and remove one', () => {
home.visit(); home.visit();
// Add name filter
useFilter('name', 'Name', 'My'); useFilter('name', 'Name', 'My');
useFilter('podConfig', 'Pod Config', 'Tiny'); cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2);
cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1);
cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); 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-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-1']").contains('My First Jupyter Notebook');
cy.get("[id$='workspaces-table-row-2']").contains('My Second 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', () => { it('filter rows with multiple filters and remove all', () => {
home.visit(); home.visit();
// Add name filter
useFilter('name', 'Name', 'My'); useFilter('name', 'Name', 'My');
useFilter('podConfig', 'Pod Config', 'Tiny'); cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2);
cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1);
cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); 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('*').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', '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); cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2);
}); });
}); });

View File

@ -10,6 +10,7 @@ import {
Tooltip, Tooltip,
Bullseye, Bullseye,
Button, Button,
Icon,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { import {
Table, Table,
@ -36,7 +37,6 @@ import {
FilterableDataFieldKey, FilterableDataFieldKey,
SortableDataFieldKey, SortableDataFieldKey,
} from '~/app/filterableDataHelper'; } from '~/app/filterableDataHelper';
import { ExpandedWorkspaceRow } from '~/app/pages/Workspaces/ExpandedWorkspaceRow';
import { useTypedNavigate } from '~/app/routerHelper'; import { useTypedNavigate } from '~/app/routerHelper';
import { import {
buildKindLogoDictionary, buildKindLogoDictionary,
@ -47,11 +47,12 @@ import { WorkspaceConnectAction } from '~/app/pages/Workspaces/WorkspaceConnectA
import CustomEmptyState from '~/shared/components/CustomEmptyState'; import CustomEmptyState from '~/shared/components/CustomEmptyState';
import Filter, { FilteredColumn, FilterRef } from '~/shared/components/Filter'; import Filter, { FilteredColumn, FilterRef } from '~/shared/components/Filter';
import WithValidImage from '~/shared/components/WithValidImage'; import WithValidImage from '~/shared/components/WithValidImage';
import ImageFallback from '~/shared/components/ImageFallback';
import { import {
formatResourceFromWorkspace, formatResourceFromWorkspace,
formatWorkspaceIdleState, formatWorkspaceIdleState,
} from '~/shared/utilities/WorkspaceUtils'; } from '~/shared/utilities/WorkspaceUtils';
import ImageFallback from '~/shared/components/ImageFallback'; import { ExpandedWorkspaceRow } from '~/app/pages/Workspaces/ExpandedWorkspaceRow';
const { const {
fields: wsTableColumns, fields: wsTableColumns,
@ -59,21 +60,16 @@ const {
sortableKeyArray: sortableWsTableColumnKeyArray, sortableKeyArray: sortableWsTableColumnKeyArray,
filterableKeyArray: filterableWsTableColumnKeyArray, filterableKeyArray: filterableWsTableColumnKeyArray,
} = defineDataFields({ } = defineDataFields({
redirectStatus: { label: 'Redirect Status', isFilterable: false, isSortable: false }, name: { label: 'Name', isFilterable: true, isSortable: true, width: 35 },
name: { label: 'Name', isFilterable: true, isSortable: true }, image: { label: 'Image', isFilterable: true, isSortable: true, width: 25 },
kind: { label: 'Kind', isFilterable: true, isSortable: true }, kind: { label: 'Kind', isFilterable: true, isSortable: true, width: 15 },
namespace: { label: 'Namespace', isFilterable: true, isSortable: true }, namespace: { label: 'Namespace', isFilterable: true, isSortable: true, width: 15 },
image: { label: 'Image', isFilterable: true, isSortable: true }, state: { label: 'State', isFilterable: true, isSortable: true, width: 15 },
podConfig: { label: 'Pod Config', isFilterable: true, isSortable: true }, gpu: { label: 'GPU', isFilterable: true, isSortable: true, width: 15 },
state: { label: 'State', isFilterable: true, isSortable: true }, idleGpu: { label: 'Idle GPU', isFilterable: true, isSortable: true, width: 15 },
homeVol: { label: 'Home Vol', isFilterable: true, isSortable: true }, lastActivity: { label: 'Last activity', isFilterable: false, isSortable: true, width: 15 },
cpu: { label: 'CPU', isFilterable: false, isSortable: true }, connect: { label: '', isFilterable: false, isSortable: false, width: 25 },
ram: { label: 'Memory', isFilterable: false, isSortable: true }, actions: { label: '', isFilterable: false, isSortable: false, width: 10 },
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 },
}); });
export type WorkspaceTableColumnKeys = DataFieldKey<typeof wsTableColumns>; export type WorkspaceTableColumnKeys = DataFieldKey<typeof wsTableColumns>;
@ -202,16 +198,12 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
return ws.namespace.match(searchValueInput); return ws.namespace.match(searchValueInput);
case 'image': case 'image':
return ws.podTemplate.options.imageConfig.current.displayName.match(searchValueInput); return ws.podTemplate.options.imageConfig.current.displayName.match(searchValueInput);
case 'podConfig':
return ws.podTemplate.options.podConfig.current.displayName.match(searchValueInput);
case 'state': case 'state':
return ws.state.match(searchValueInput); return ws.state.match(searchValueInput);
case 'gpu': case 'gpu':
return formatResourceFromWorkspace(ws, 'gpu').match(searchValueInput); return formatResourceFromWorkspace(ws, 'gpu').match(searchValueInput);
case 'idleGpu': case 'idleGpu':
return formatWorkspaceIdleState(ws).match(searchValueInput); return formatWorkspaceIdleState(ws).match(searchValueInput);
case 'homeVol':
return ws.podTemplate.volumes.home?.mountPath.match(searchValueInput);
default: default:
return true; return true;
} }
@ -228,11 +220,7 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
kind: workspace.workspaceKind.name, kind: workspace.workspaceKind.name,
namespace: workspace.namespace, namespace: workspace.namespace,
image: workspace.podTemplate.options.imageConfig.current.displayName, image: workspace.podTemplate.options.imageConfig.current.displayName,
podConfig: workspace.podTemplate.options.podConfig.current.displayName,
state: workspace.state, state: workspace.state,
homeVol: workspace.podTemplate.volumes.home?.pvcName ?? '',
cpu: formatResourceFromWorkspace(workspace, 'cpu'),
ram: formatResourceFromWorkspace(workspace, 'memory'),
gpu: formatResourceFromWorkspace(workspace, 'gpu'), gpu: formatResourceFromWorkspace(workspace, 'gpu'),
idleGpu: formatWorkspaceIdleState(workspace), idleGpu: formatWorkspaceIdleState(workspace),
lastActivity: workspace.activity.lastActivity, 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) => { const extractStateColor = (state: WorkspaceState) => {
switch (state) { switch (state) {
case WorkspaceState.WorkspaceStateRunning: case WorkspaceState.WorkspaceStateRunning:
@ -305,31 +324,41 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
case 'Info': case 'Info':
return ( return (
<Tooltip content={message}> <Tooltip content={message}>
<InfoCircleIcon color="blue" aria-hidden="true" /> <Icon status="info" isInline>
<InfoCircleIcon aria-hidden="true" />
</Icon>
</Tooltip> </Tooltip>
); );
case 'Warning': case 'Warning':
return ( return (
<Tooltip content={message}> <Tooltip content={message}>
<ExclamationTriangleIcon color="orange" aria-hidden="true" /> <Icon isInline>
<ExclamationTriangleIcon color="orange" aria-hidden="true" />
</Icon>
</Tooltip> </Tooltip>
); );
case 'Danger': case 'Danger':
return ( return (
<Tooltip content={message}> <Tooltip content={message}>
<TimesCircleIcon color="red" aria-hidden="true" /> <Icon isInline>
<TimesCircleIcon color="red" aria-hidden="true" />
</Icon>
</Tooltip> </Tooltip>
); );
case undefined: case undefined:
return ( return (
<Tooltip content={message}> <Tooltip content={message}>
<QuestionCircleIcon color="gray" aria-hidden="true" /> <Icon isInline>
<QuestionCircleIcon color="gray" aria-hidden="true" />
</Icon>
</Tooltip> </Tooltip>
); );
default: default:
return ( return (
<Tooltip content={`Invalid level: ${level}`}> <Tooltip content={`Invalid level: ${level}`}>
<QuestionCircleIcon color="gray" aria-hidden="true" /> <Icon isInline>
<QuestionCircleIcon color="gray" aria-hidden="true" />
</Icon>
</Tooltip> </Tooltip>
); );
} }
@ -371,19 +400,32 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
} }
/> />
</Content> </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> <Thead>
<Tr> <Tr>
{canExpandRows && <Th screenReaderText="expand-action" />} {canExpandRows && <Th width={10} screenReaderText="expand-action" />}
{visibleColumnKeys.map((columnKey) => ( {visibleColumnKeys.map((columnKey) => {
<Th const specialProps = getSpecialColumnProps(columnKey);
key={`workspace-table-column-${columnKey}`} const modifier = getColumnModifier(columnKey);
sort={getSortParams(columnKey)}
aria-label={columnKey} return (
> <Th
{wsTableColumns[columnKey].label} width={wsTableColumns[columnKey].width}
</Th> 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> </Tr>
</Thead> </Thead>
{sortedWorkspaces.length > 0 && {sortedWorkspaces.length > 0 &&
@ -397,6 +439,7 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
<Tr <Tr
id={`workspaces-table-row-${rowIndex + 1}`} id={`workspaces-table-row-${rowIndex + 1}`}
data-testid={`workspace-row-${rowIndex}`} data-testid={`workspace-row-${rowIndex}`}
isStriped={rowIndex % 2 === 0}
> >
{canExpandRows && ( {canExpandRows && (
<Td <Td
@ -409,10 +452,43 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
/> />
)} )}
{visibleColumnKeys.map((columnKey) => { {visibleColumnKeys.map((columnKey) => {
switch (columnKey) { if (columnKey === 'connect') {
case 'redirectStatus': return (
return ( <Td key="connect" isActionCell>
<Td key={columnKey} dataLabel={wsTableColumns[columnKey].label}> <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] {workspaceRedirectStatus[workspace.workspaceKind.name]
? getRedirectStatusIcon( ? getRedirectStatusIcon(
workspaceRedirectStatus[workspace.workspaceKind.name]?.message workspaceRedirectStatus[workspace.workspaceKind.name]?.message
@ -421,143 +497,57 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
?.text || 'No API response available', ?.text || 'No API response available',
) )
: getRedirectStatusIcon(undefined, 'No API response available')} : getRedirectStatusIcon(undefined, 'No API response available')}
</Td> </Content>
); )}
case 'name': {columnKey === 'kind' && (
return ( <WithValidImage
<Td imageSrc={kindLogoDict[workspace.workspaceKind.name]}
key={columnKey} skeletonWidth="20px"
data-testid="workspace-name" fallback={
dataLabel={wsTableColumns[columnKey].label} <ImageFallback
imageSrc={kindLogoDict[workspace.workspaceKind.name]}
/>
}
> >
{workspace.name} {(validSrc) => (
</Td> <Tooltip content={workspace.workspaceKind.name}>
); <img
case 'kind': src={validSrc}
return ( alt={workspace.workspaceKind.name}
<Td key={columnKey} dataLabel={wsTableColumns[columnKey].label}> style={{ width: '20px', height: '20px', cursor: 'pointer' }}
<WithValidImage
imageSrc={kindLogoDict[workspace.workspaceKind.name]}
skeletonWidth="20px"
fallback={
<ImageFallback
imageSrc={kindLogoDict[workspace.workspaceKind.name]}
/> />
} </Tooltip>
> )}
{(validSrc) => ( </WithValidImage>
<Tooltip content={workspace.workspaceKind.name}> )}
<img {columnKey === 'namespace' && workspace.namespace}
src={validSrc} {columnKey === 'state' && (
alt={workspace.workspaceKind.name} <Label color={extractStateColor(workspace.state)}>
style={{ width: '20px', height: '20px', cursor: 'pointer' }} {workspace.state}
/> </Label>
</Tooltip> )}
)} {columnKey === 'gpu' && formatResourceFromWorkspace(workspace, 'gpu')}
</WithValidImage> {columnKey === 'idleGpu' && formatWorkspaceIdleState(workspace)}
</Td> {columnKey === 'lastActivity' && (
); <Timestamp
case 'namespace': date={new Date(workspace.activity.lastActivity)}
return ( tooltip={{ variant: TimestampTooltipVariant.default }}
<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}
> >
{workspace.podTemplate.options.podConfig.current.displayName} {formatDistanceToNow(new Date(workspace.activity.lastActivity), {
</Td> addSuffix: true,
); })}
case 'state': </Timestamp>
return ( )}
<Td </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;
}
})} })}
</Tr> </Tr>
{isWorkspaceExpanded(workspace) && ( {isWorkspaceExpanded(workspace) && (
<ExpandedWorkspaceRow workspace={workspace} columnKeys={visibleColumnKeys} /> <ExpandedWorkspaceRow
workspace={workspace}
visibleColumnKeys={visibleColumnKeys}
canExpandRows={canExpandRows}
/>
)} )}
</Tbody> </Tbody>
))} ))}

View File

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

View File

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

View File

@ -3,12 +3,14 @@ import {
ClipboardCopy, ClipboardCopy,
ClipboardCopyVariant, ClipboardCopyVariant,
Content, Content,
DescriptionList,
DescriptionListGroup,
DescriptionListTerm,
DescriptionListDescription,
Flex, Flex,
FlexItem, FlexItem,
List, List,
ListItem, ListItem,
Stack,
StackItem,
Tooltip, Tooltip,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { DatabaseIcon, LockedIcon } from '@patternfly/react-icons'; import { DatabaseIcon, LockedIcon } from '@patternfly/react-icons';
@ -43,32 +45,32 @@ export const DataVolumesList: React.FC<DataVolumesListProps> = ({ workspace }) =
</Tooltip> </Tooltip>
)} )}
</Content> </Content>
<Stack> <Flex gap={{ default: 'gapSm' }} flexWrap={{ default: 'wrap' }}>
<StackItem> <FlexItem>Mount path:</FlexItem>
<Content component="small"> <FlexItem>
Mount path:{' '} <ClipboardCopy variant={ClipboardCopyVariant.inlineCompact} isCode>
<ClipboardCopy variant={ClipboardCopyVariant.inlineCompact}> {data.mountPath}
{data.mountPath} </ClipboardCopy>
</ClipboardCopy> </FlexItem>
</Content> </Flex>
</StackItem>
</Stack>
</FlexItem> </FlexItem>
</Flex> </Flex>
); );
return ( return (
<Stack hasGutter> <DescriptionList>
<StackItem> <DescriptionListGroup>
<strong data-testid="notebook-storage-bar-title">Cluster storage</strong> <DescriptionListTerm data-testid="notebook-storage-bar-title">
</StackItem> Cluster storage
<StackItem> </DescriptionListTerm>
<List isPlain> <DescriptionListDescription>
{workspaceDataVol.map((data, index) => ( <List isPlain>
<ListItem key={`data-vol-${index}`}>{singleDataVolRenderer(data)}</ListItem> {workspaceDataVol.map((data, index) => (
))} <ListItem key={`data-vol-${index}`}>{singleDataVolRenderer(data)}</ListItem>
</List> ))}
</StackItem> </List>
</Stack> </DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
); );
}; };

View File

@ -1,38 +1,74 @@
import React from 'react'; 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 { Workspace } from '~/shared/api/backendApiTypes';
import { DataVolumesList } from '~/app/pages/Workspaces/DataVolumesList';
import { WorkspaceTableColumnKeys } from '~/app/components/WorkspaceTable'; import { WorkspaceTableColumnKeys } from '~/app/components/WorkspaceTable';
import { WorkspaceStorage } from './WorkspaceStorage';
import { WorkspacePackageDetails } from './WorkspacePackageDetails';
import { WorkspaceConfigDetails } from './WorkspaceConfigDetails';
interface ExpandedWorkspaceRowProps { interface ExpandedWorkspaceRowProps {
workspace: Workspace; workspace: Workspace;
columnKeys: WorkspaceTableColumnKeys[]; visibleColumnKeys: WorkspaceTableColumnKeys[];
canExpandRows: boolean;
} }
export const ExpandedWorkspaceRow: React.FC<ExpandedWorkspaceRowProps> = ({ export const ExpandedWorkspaceRow: React.FC<ExpandedWorkspaceRowProps> = ({
workspace, workspace,
columnKeys, visibleColumnKeys,
canExpandRows,
}) => { }) => {
const renderExpandedData = () => // Calculate total number of columns (including expand column if present)
columnKeys.map((colKey, index) => { const totalColumns = visibleColumnKeys.length + (canExpandRows ? 1 : 0);
switch (colKey) {
case 'name': // 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 ( return (
<Td noPadding colSpan={1} key={colKey}> <Td key={`storage-${index}`} dataLabel="Storage" modifier="nowrap">
<ExpandableRowContent> <ExpandableRowContent>
<DataVolumesList workspace={workspace} /> <WorkspaceStorage workspace={workspace} />
</ExpandableRowContent> </ExpandableRowContent>
</Td> </Td>
); );
default: }
return <Td key={index} />;
}
});
return ( if (index === packageColumnIndex) {
<Tr> return (
<Td /> <Td key={`package-${index}`} modifier="nowrap">
{renderExpandedData()} <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> </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>) => ( toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle <MenuToggle
ref={toggleRef} ref={toggleRef}
variant="secondary"
onClick={onToggleClick} onClick={onToggleClick}
isExpanded={open} isExpanded={open}
isDisabled={workspace.state !== WorkspaceState.WorkspaceStateRunning} isDisabled={workspace.state !== WorkspaceState.WorkspaceStateRunning}
@ -60,6 +61,7 @@ export const WorkspaceConnectAction: React.FunctionComponent<WorkspaceConnectAct
id="connect-endpoint-button" id="connect-endpoint-button"
key="connect-endpoint-button" key="connect-endpoint-button"
onClick={onClickConnect} onClick={onClickConnect}
className="connect-button-no-wrap"
> >
Connect Connect
</MenuToggleAction>, </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', key: 'pythonVersion',
value: '3.11', value: '3.11',
}, },
{
key: 'jupyterlabVersion',
value: '1.9.0',
},
], ],
}, },
}, },
@ -290,9 +294,30 @@ export const buildMockWorkspaceList = (args: {
}): Workspace[] => { }): Workspace[] => {
const states = Object.values(WorkspaceState); const states = Object.values(WorkspaceState);
const imageConfigs = [ 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_190',
{ id: 'jupyterlab_scipy_210', displayName: `jupyter-scipy:v2.1.0` }, 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 = [ const podConfigs = [
{ id: 'tiny_cpu', displayName: 'Tiny CPU' }, { id: 'tiny_cpu', displayName: 'Tiny CPU' },
@ -353,12 +378,7 @@ export const buildMockWorkspaceList = (args: {
id: imageConfig.id, id: imageConfig.id,
displayName: imageConfig.displayName, displayName: imageConfig.displayName,
description: 'JupyterLab, with SciPy Packages', description: 'JupyterLab, with SciPy Packages',
labels: [ labels: imageConfig.labels,
{
key: 'pythonVersion',
value: '3.11',
},
],
}, },
}, },
podConfig: { podConfig: {

View File

@ -15,6 +15,7 @@
// Button // Button
--mui-button-font-weight: 500; --mui-button-font-weight: 500;
--mui-button--BorderWidth: 1px;
--mui-button--hover--BorderWidth: 1px; --mui-button--hover--BorderWidth: 1px;
--mui-button--PaddingBlockStart: 6px; --mui-button--PaddingBlockStart: 6px;
--mui-button--PaddingBlockEnd: 6px; --mui-button--PaddingBlockEnd: 6px;
@ -76,7 +77,7 @@
--mui-table--cell--PaddingInlineEnd: 16px; --mui-table--cell--PaddingInlineEnd: 16px;
--mui-table--cell--PaddingBlockStart: 16px; --mui-table--cell--PaddingBlockStart: 16px;
--mui-table--cell--PaddingBlockEnd: 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__thead--cell--FontSize: 14px;
--mui-table__sort-indicator--MarginInlineStart: 4px; --mui-table__sort-indicator--MarginInlineStart: 4px;
@ -116,7 +117,6 @@
} }
.mui-theme .pf-v6-c-action-list__item .pf-v6-c-button { .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--PaddingInlineStart: none;
--pf-v6-c-button--PaddingInlineEnd: none; --pf-v6-c-button--PaddingInlineEnd: none;
} }
@ -153,7 +153,6 @@
--pf-v6-c-button--PaddingInlineStart: var(--mui-button--PaddingInlineStart); --pf-v6-c-button--PaddingInlineStart: var(--mui-button--PaddingInlineStart);
--pf-v6-c-button--PaddingInlineEnd: var(--mui-button--PaddingInlineEnd); --pf-v6-c-button--PaddingInlineEnd: var(--mui-button--PaddingInlineEnd);
--pf-v6-c-button--LineHeight: var(--mui-button--LineHeight); --pf-v6-c-button--LineHeight: var(--mui-button--LineHeight);
--pf-v6-c-button--m-plain--BorderRadius: 50%;
text-transform: var(--mui-text-transform); text-transform: var(--mui-text-transform);
letter-spacing: 0.02857em; letter-spacing: 0.02857em;
@ -198,10 +197,6 @@
--pf-v6-c-card--m-selectable--m-selected--BorderColor: none; --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 { .mui-theme .pf-v6-c-description-list__term {
font: var(--mui-font-subtitle2); font: var(--mui-font-subtitle2);
} }
@ -238,20 +233,26 @@
--pf-v6-c-form__section-title--MarginInlineEnd: 0px; --pf-v6-c-form__section-title--MarginInlineEnd: 0px;
} }
// Base form label styles
.mui-theme .pf-v6-c-form__label { .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; position: absolute;
top: 35%; top: 35%;
left: 12px; left: 12px;
font-size: 14px; font-size: 14px;
color: var(--mui-palette-grey-600);
pointer-events: none;
transition: all 0.2s ease;
transform-origin: left center; transform-origin: left center;
transform: translateY(-50%) scale(0.75); transform: translateY(-50%) scale(0.75);
background-color: var(--mui-palette-common-white); background-color: var(--mui-palette-common-white);
padding: 0 4px; padding: 0 4px;
z-index: 1; z-index: 1;
--pf-v6-c-form__label-required--Color: currentColor;
} }
.mui-theme .pf-v6-c-form-control input::placeholder { .mui-theme .pf-v6-c-form-control input::placeholder {
@ -499,10 +500,6 @@
align-self: stretch; 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 { .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--Color: var(--mui-palette-common-white);
--pf-v6-c-menu-toggle--expanded--BackgroundColor: var(--mui-palette-primary-main); --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--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) { .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--PaddingBlockStart: var(--mui-spacing-4);
--pf-v6-c-menu-toggle--PaddingBlockEnd: var(--mui-spacing-4); --pf-v6-c-menu-toggle--PaddingBlockEnd: var(--mui-spacing-4);
@ -710,6 +714,12 @@
align-content: center; 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 { .mui-theme .pf-v6-c-label {
--pf-v6-c-label--BorderRadius: 16px; --pf-v6-c-label--BorderRadius: 16px;
--pf-v6-c-label--FontSize: 0.8125rem; --pf-v6-c-label--FontSize: 0.8125rem;
@ -760,10 +770,6 @@
--pf-v6-c-modal-box--BoxShadow: var(--mui-shadows-24); --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 { .mui-theme .pf-v6-c-page__main-container {
--pf-v6-c-page__main-container--BorderWidth: 0px; --pf-v6-c-page__main-container--BorderWidth: 0px;
--pf-v6-c-page__main-container--BorderRadius: var(--mui-shape-borderRadius); --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 { import {
CPU_UNITS, CPU_UNITS,
MEMORY_UNITS_FOR_PARSING, MEMORY_UNITS_FOR_PARSING,
@ -93,3 +93,24 @@ export const countGpusFromWorkspaces = (workspaces: Workspace[]): number =>
const [gpuValue] = splitValueUnit(extractResourceValue(workspace, 'gpu') || '0', OTHER); const [gpuValue] = splitValueUnit(extractResourceValue(workspace, 'gpu') || '0', OTHER);
return total + (gpuValue ?? 0); return total + (gpuValue ?? 0);
}, 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),
);