fix(ws): Refactors toolbar and filter logic to fix "clear all filters" bug in workspace list view (#502)

Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com>

remove comment

fix(ws): remove set to first page when filters applied

Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com>

fix tests for filterWorkspacesTest

fix single filter test

Signed-off-by: Jenny <32821331+jenny-s51@users.noreply.github.com>

fix bug in ws kind table
This commit is contained in:
Jenny 2025-07-29 07:18:47 -04:00 committed by Bhakti Narvekar
parent bdbfe1bbd2
commit 7bed0beec1
6 changed files with 328 additions and 324 deletions

View File

@ -16,13 +16,21 @@ describe('Application', () => {
cy.intercept('GET', '/api/v1/namespaces', {
body: mockBFFResponse(mockNamespaces),
});
cy.intercept('GET', '/api/v1/workspaces', {
body: mockBFFResponse(mockWorkspaces),
}).as('getWorkspaces');
cy.intercept('GET', '/api/v1/workspaces/default', {
body: mockBFFResponse(mockWorkspaces),
});
cy.intercept('GET', '/api/namespaces/test-namespace/workspaces').as('getWorkspaces');
});
it('filter rows with single filter', () => {
home.visit();
// Wait for the API call before trying to interact with the UI
cy.wait('@getWorkspaces');
useFilter('name', 'Name', 'My');
cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2);
cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook');

View File

@ -1,5 +1,4 @@
import React, { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { PageSection } from '@patternfly/react-core/dist/esm/components/Page';
import React, { useCallback, useImperativeHandle, useMemo, useState } from 'react';
import {
TimestampTooltipVariant,
Timestamp,
@ -14,6 +13,20 @@ import { Tooltip } from '@patternfly/react-core/dist/esm/components/Tooltip';
import { Bullseye } from '@patternfly/react-core/dist/esm/layouts/Bullseye';
import { Button } from '@patternfly/react-core/dist/esm/components/Button';
import { Icon } from '@patternfly/react-core/dist/esm/components/Icon';
import {
Toolbar,
ToolbarContent,
ToolbarItem,
ToolbarGroup,
ToolbarFilter,
ToolbarToggleGroup,
} from '@patternfly/react-core/dist/esm/components/Toolbar';
import {
Select,
SelectList,
SelectOption,
} from '@patternfly/react-core/dist/esm/components/Select';
import { MenuToggle } from '@patternfly/react-core/dist/esm/components/MenuToggle';
import {
Table,
Thead,
@ -25,18 +38,14 @@ import {
ActionsColumn,
IActions,
} from '@patternfly/react-table/dist/esm/components/Table';
import { FilterIcon } from '@patternfly/react-icons/dist/esm/icons/filter-icon';
import { InfoCircleIcon } from '@patternfly/react-icons/dist/esm/icons/info-circle-icon';
import { ExclamationTriangleIcon } from '@patternfly/react-icons/dist/esm/icons/exclamation-triangle-icon';
import { TimesCircleIcon } from '@patternfly/react-icons/dist/esm/icons/times-circle-icon';
import { QuestionCircleIcon } from '@patternfly/react-icons/dist/esm/icons/question-circle-icon';
import { formatDistanceToNow } from 'date-fns/formatDistanceToNow';
import { Workspace, WorkspaceState } from '~/shared/api/backendApiTypes';
import {
DataFieldKey,
defineDataFields,
FilterableDataFieldKey,
SortableDataFieldKey,
} from '~/app/filterableDataHelper';
import { DataFieldKey, defineDataFields, SortableDataFieldKey } from '~/app/filterableDataHelper';
import { useTypedNavigate } from '~/app/routerHelper';
import {
buildKindLogoDictionary,
@ -44,8 +53,7 @@ import {
} from '~/app/actions/WorkspaceKindsActions';
import useWorkspaceKinds from '~/app/hooks/useWorkspaceKinds';
import { WorkspaceConnectAction } from '~/app/pages/Workspaces/WorkspaceConnectAction';
import CustomEmptyState from '~/shared/components/CustomEmptyState';
import Filter, { FilteredColumn, FilterRef } from '~/shared/components/Filter';
import ThemeAwareSearchInput from '~/app/components/ThemeAwareSearchInput';
import WithValidImage from '~/shared/components/WithValidImage';
import ImageFallback from '~/shared/components/ImageFallback';
import {
@ -53,12 +61,12 @@ import {
formatWorkspaceIdleState,
} from '~/shared/utilities/WorkspaceUtils';
import { ExpandedWorkspaceRow } from '~/app/pages/Workspaces/ExpandedWorkspaceRow';
import CustomEmptyState from '~/shared/components/CustomEmptyState';
const {
fields: wsTableColumns,
keyArray: wsTableColumnKeyArray,
sortableKeyArray: sortableWsTableColumnKeyArray,
filterableKeyArray: filterableWsTableColumnKeyArray,
} = defineDataFields({
name: { label: 'Name', isFilterable: true, isSortable: true, width: 35 },
image: { label: 'Image', isFilterable: true, isSortable: true, width: 25 },
@ -73,21 +81,40 @@ const {
});
export type WorkspaceTableColumnKeys = DataFieldKey<typeof wsTableColumns>;
type WorkspaceTableFilterableColumnKeys = FilterableDataFieldKey<typeof wsTableColumns>;
type WorkspaceTableSortableColumnKeys = SortableDataFieldKey<typeof wsTableColumns>;
export type WorkspaceTableFilteredColumn = FilteredColumn<WorkspaceTableFilterableColumnKeys>;
interface WorkspaceTableProps {
workspaces: Workspace[];
canCreateWorkspaces?: boolean;
canExpandRows?: boolean;
initialFilters?: WorkspaceTableFilteredColumn[];
hiddenColumns?: WorkspaceTableColumnKeys[];
rowActions?: (workspace: Workspace) => IActions;
}
const allFiltersConfig = {
name: { label: 'Name', placeholder: 'Filter by name' },
kind: { label: 'Kind', placeholder: 'Filter by kind' },
image: { label: 'Image', placeholder: 'Filter by image' },
state: { label: 'State', placeholder: 'Filter by state' },
namespace: { label: 'Namespace' },
idleGpu: { label: 'Idle GPU' },
} as const;
// Defines which of the above filters should appear in the dropdown
const dropdownFilterKeys = ['name', 'kind', 'image', 'state'] as const;
const filterConfigs = dropdownFilterKeys.map((key) => ({
key,
label: allFiltersConfig[key].label,
placeholder: allFiltersConfig[key].placeholder!, // '!' asserts placeholder is not undefined here
}));
type FilterKey = keyof typeof allFiltersConfig;
type FilterLabel = (typeof allFiltersConfig)[FilterKey]['label'];
export interface WorkspaceTableRef {
addFilter: (filter: WorkspaceTableFilteredColumn) => void;
clearAllFilters: () => void;
setFilter: (key: FilterKey, value: string) => void;
}
const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
@ -96,7 +123,6 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
workspaces,
canCreateWorkspaces = true,
canExpandRows = true,
initialFilters = [],
hiddenColumns = [],
rowActions = () => [],
},
@ -104,7 +130,15 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
) => {
const [workspaceKinds] = useWorkspaceKinds();
const [expandedWorkspacesNames, setExpandedWorkspacesNames] = useState<string[]>([]);
const [filters, setFilters] = useState<FilteredColumn[]>(initialFilters);
const [filters, setFilters] = useState<Record<FilterKey, string>>({
name: '',
kind: '',
image: '',
state: '',
namespace: '',
idleGpu: '',
});
const [activeSortColumnKey, setActiveSortColumnKey] =
useState<WorkspaceTableSortableColumnKeys | null>(null);
const [activeSortDirection, setActiveSortDirection] = useState<'asc' | 'desc' | null>(null);
@ -112,10 +146,66 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
const [perPage, setPerPage] = useState(10);
const navigate = useTypedNavigate();
const filterRef = useRef<FilterRef>(null);
const kindLogoDict = buildKindLogoDictionary(workspaceKinds);
const workspaceRedirectStatus = buildWorkspaceRedirectStatus(workspaceKinds);
// Use the derived FilterLabel type for the active menu
const [activeAttributeMenu, setActiveAttributeMenu] = useState<FilterLabel>('Name');
const [isAttributeMenuOpen, setIsAttributeMenuOpen] = useState(false);
const handleFilterChange = useCallback((key: FilterKey, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const clearAllFilters = useCallback(() => {
setFilters({
name: '',
kind: '',
image: '',
state: '',
namespace: '',
idleGpu: '',
});
}, []);
const onAttributeToggleClick = useCallback(() => {
setIsAttributeMenuOpen((prev) => !prev);
}, []);
const attributeDropdown = useMemo(
() => (
<Select
isOpen={isAttributeMenuOpen}
onSelect={(_ev, itemId) => {
setActiveAttributeMenu(itemId?.toString() as FilterLabel);
setIsAttributeMenuOpen(false);
}}
selected={activeAttributeMenu}
onOpenChange={(isOpen) => setIsAttributeMenuOpen(isOpen)}
toggle={(toggleRef) => (
<MenuToggle
ref={toggleRef}
id="filter-workspaces-dropdown"
onClick={onAttributeToggleClick}
isExpanded={isAttributeMenuOpen}
icon={<FilterIcon />}
>
{activeAttributeMenu}
</MenuToggle>
)}
>
<SelectList>
{filterConfigs.map(({ key, label }) => (
<SelectOption key={key} value={label} id={`filter-workspaces-dropdown-${key}`}>
{label}
</SelectOption>
))}
</SelectList>
</Select>
),
[isAttributeMenuOpen, activeAttributeMenu, onAttributeToggleClick],
);
const visibleColumnKeys: WorkspaceTableColumnKeys[] = useMemo(
() =>
hiddenColumns.length
@ -129,39 +219,32 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
[visibleColumnKeys],
);
const visibleFilterableColumnKeys: WorkspaceTableFilterableColumnKeys[] = useMemo(
() => filterableWsTableColumnKeyArray.filter((col) => visibleColumnKeys.includes(col)),
[visibleColumnKeys],
);
const visibleFilterableColumnMap = useMemo(
() =>
Object.fromEntries(
visibleFilterableColumnKeys.map((key) => [key, wsTableColumns[key].label]),
) as Record<WorkspaceTableFilterableColumnKeys, string>,
[visibleFilterableColumnKeys],
);
useImperativeHandle(ref, () => ({
addFilter: (newFilter: WorkspaceTableFilteredColumn) => {
if (!visibleFilterableColumnKeys.includes(newFilter.columnKey)) {
return;
}
setFilters((prev) => {
const existingIndex = prev.findIndex((f) => f.columnKey === newFilter.columnKey);
if (existingIndex !== -1) {
return prev.map((f, i) => (i === existingIndex ? newFilter : f));
}
return [...prev, newFilter];
});
},
clearAllFilters,
setFilter: handleFilterChange,
}));
const createWorkspace = useCallback(() => {
navigate('workspaceCreate');
}, [navigate]);
const emptyState = useMemo(
() => <CustomEmptyState onClearFilters={clearAllFilters} />,
[clearAllFilters],
);
const filterableProperties: Record<FilterKey, (ws: Workspace) => string> = useMemo(
() => ({
name: (ws) => ws.name,
kind: (ws) => ws.workspaceKind.name,
image: (ws) => ws.podTemplate.options.imageConfig.current.displayName,
state: (ws) => ws.state,
namespace: (ws) => ws.namespace,
idleGpu: (ws) => formatWorkspaceIdleState(ws),
}),
[],
);
const setWorkspaceExpanded = (workspace: Workspace, isExpanding = true) =>
setExpandedWorkspacesNames((prevExpanded) => {
const newExpandedWorkspacesNames = prevExpanded.filter(
@ -179,37 +262,29 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
if (workspaces.length === 0) {
return [];
}
return filters.reduce((result, filter) => {
let searchValueInput: RegExp;
try {
searchValueInput = new RegExp(filter.value, 'i');
} catch {
searchValueInput = new RegExp(filter.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
}
return result.filter((ws) => {
switch (filter.columnKey as WorkspaceTableFilterableColumnKeys) {
case 'name':
return ws.name.match(searchValueInput);
case 'kind':
return ws.workspaceKind.name.match(searchValueInput);
case 'namespace':
return ws.namespace.match(searchValueInput);
case 'image':
return ws.podTemplate.options.imageConfig.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);
default:
const testRegex = (value: string, searchValue: string) => {
if (!searchValue) {
return true;
}
});
}, workspaces);
}, [workspaces, filters]);
try {
return new RegExp(searchValue, 'i').test(value);
} catch {
return new RegExp(searchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(value);
}
};
const activeFilters = Object.entries(filters).filter(([, value]) => value);
if (activeFilters.length === 0) {
return workspaces;
}
return workspaces.filter((ws) =>
activeFilters.every(([key, searchValue]) => {
const propertyGetter = filterableProperties[key as FilterKey];
return testRegex(propertyGetter(ws), searchValue);
}),
);
}, [workspaces, filters, filterableProperties]);
// Column sorting
@ -317,8 +392,6 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
}
};
// Redirect Status Icons
const getRedirectStatusIcon = (level: string | undefined, message: string) => {
switch (level) {
case 'Info':
@ -383,22 +456,68 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
};
return (
<PageSection isFilled>
<>
<Content style={{ display: 'flex', alignItems: 'flex-start', columnGap: '20px' }}>
<Filter
ref={filterRef}
id="filter-workspaces"
filters={filters}
setFilters={setFilters}
columnDefinition={visibleFilterableColumnMap}
toolbarActions={
canCreateWorkspaces && (
<Button variant="primary" ouiaId="Primary" onClick={createWorkspace}>
<Toolbar id="workspace-filter-toolbar" clearAllFilters={clearAllFilters}>
<ToolbarContent>
<ToolbarToggleGroup toggleIcon={<FilterIcon />} breakpoint="xl">
<ToolbarGroup variant="filter-group">
<ToolbarItem>{attributeDropdown}</ToolbarItem>
{filterConfigs.map(({ key, label, placeholder }) => (
<ToolbarFilter
key={key}
labels={filters[key] ? [filters[key]] : []}
deleteLabel={() => handleFilterChange(key, '')}
deleteLabelGroup={() => handleFilterChange(key, '')}
categoryName={label}
showToolbarItem={activeAttributeMenu === label}
>
<ToolbarItem>
<ThemeAwareSearchInput
value={filters[key]}
onChange={(value: string) => handleFilterChange(key, value)}
placeholder={placeholder}
fieldLabel={placeholder}
aria-label={placeholder}
data-testid="filter-workspaces-search-input"
/>
</ToolbarItem>
</ToolbarFilter>
))}
{Object.entries(filters).map(([key, value]) => {
// Check if the key is not in the dropdown config and has a value
const isWsSummaryFilter = !filterConfigs.some((config) => config.key === key);
if (!isWsSummaryFilter || !value) {
return null;
}
return (
<ToolbarFilter
key={key}
labels={[value]}
deleteLabel={() => handleFilterChange(key as FilterKey, '')}
categoryName={allFiltersConfig[key as FilterKey].label}
// eslint-disable-next-line react/no-children-prop
children={undefined}
/>
);
})}
{canCreateWorkspaces && (
<ToolbarItem>
<Button
size="lg"
variant="primary"
ouiaId="Primary"
onClick={createWorkspace}
>
Create workspace
</Button>
)
}
/>
</ToolbarItem>
)}
</ToolbarGroup>
</ToolbarToggleGroup>
</ToolbarContent>
</Toolbar>
</Content>
<Table
data-testid="workspaces-table"
@ -552,15 +671,11 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
</Tbody>
))}
{sortedWorkspaces.length === 0 && (
<Tbody>
<Tr>
<Td colSpan={12} id="empty-state-cell">
<Bullseye>
<CustomEmptyState onClearFilters={() => filterRef.current?.clearAll()} />
</Bullseye>
<Td colSpan={8} id="empty-state-cell">
<Bullseye>{emptyState}</Bullseye>
</Td>
</Tr>
</Tbody>
)}
</Table>
<Pagination
@ -573,7 +688,7 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
onSetPage={onSetPage}
onPerPageSelect={onPerPageSelect}
/>
</PageSection>
</>
);
},
);

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import {
Drawer,
DrawerContent,
@ -17,13 +17,11 @@ import {
ToolbarToggleGroup,
} from '@patternfly/react-core/dist/esm/components/Toolbar';
import {
Menu,
MenuContent,
MenuList,
MenuItem,
} from '@patternfly/react-core/dist/esm/components/Menu';
Select,
SelectList,
SelectOption,
} from '@patternfly/react-core/dist/esm/components/Select';
import { MenuToggle } from '@patternfly/react-core/dist/esm/components/MenuToggle';
import { Popper } from '@patternfly/react-core/helpers';
import { Bullseye } from '@patternfly/react-core/dist/esm/layouts/Bullseye';
import { Button } from '@patternfly/react-core/dist/esm/components/Button';
import {
@ -200,110 +198,40 @@ export const WorkspaceKinds: React.FunctionComponent = () => {
// Set up status single select
const [isStatusMenuOpen, setIsStatusMenuOpen] = useState<boolean>(false);
const statusToggleRef = useRef<HTMLButtonElement>(null);
const statusMenuRef = useRef<HTMLDivElement>(null);
const statusContainerRef = useRef<HTMLDivElement>(null);
const handleStatusMenuKeys = useCallback(
(event: KeyboardEvent) => {
if (isStatusMenuOpen && statusMenuRef.current?.contains(event.target as Node)) {
if (event.key === 'Escape' || event.key === 'Tab') {
setIsStatusMenuOpen(!isStatusMenuOpen);
statusToggleRef.current?.focus();
}
}
},
[isStatusMenuOpen],
);
const handleStatusClickOutside = useCallback(
(event: MouseEvent) => {
if (isStatusMenuOpen && !statusMenuRef.current?.contains(event.target as Node)) {
setIsStatusMenuOpen(false);
}
},
[isStatusMenuOpen],
);
useEffect(() => {
window.addEventListener('keydown', handleStatusMenuKeys);
window.addEventListener('click', handleStatusClickOutside);
return () => {
window.removeEventListener('keydown', handleStatusMenuKeys);
window.removeEventListener('click', handleStatusClickOutside);
};
}, [isStatusMenuOpen, statusMenuRef, handleStatusClickOutside, handleStatusMenuKeys]);
const onStatusToggleClick = useCallback((ev: React.MouseEvent) => {
ev.stopPropagation();
setTimeout(() => {
const firstElement = statusMenuRef.current?.querySelector('li > button:not(:disabled)');
if (firstElement) {
(firstElement as HTMLElement).focus();
}
}, 0);
setIsStatusMenuOpen((prev) => !prev);
}, []);
const onStatusSelect = useCallback(
(event: React.MouseEvent | undefined, itemId: string | number | undefined) => {
if (typeof itemId === 'undefined') {
const onStatusSelect = (
_event: React.MouseEvent | undefined,
value: string | number | undefined,
) => {
if (typeof value === 'undefined') {
return;
}
setStatusSelection(value.toString());
setIsStatusMenuOpen(false);
};
setStatusSelection(itemId.toString());
setIsStatusMenuOpen((prev) => !prev);
},
[],
);
const statusToggle = useMemo(
() => (
const statusSelect = (
<Select
isOpen={isStatusMenuOpen}
selected={statusSelection}
onSelect={onStatusSelect}
onOpenChange={(isOpen) => setIsStatusMenuOpen(isOpen)}
toggle={(toggleRef) => (
<MenuToggle
ref={statusToggleRef}
onClick={onStatusToggleClick}
ref={toggleRef}
onClick={() => setIsStatusMenuOpen(!isStatusMenuOpen)}
isExpanded={isStatusMenuOpen}
style={{ width: '200px' } as React.CSSProperties}
>
Filter by status
</MenuToggle>
),
[isStatusMenuOpen, onStatusToggleClick],
);
const statusMenu = useMemo(
() => (
<Menu
ref={statusMenuRef}
id="attribute-search-status-menu"
onSelect={onStatusSelect}
selected={statusSelection}
)}
>
<MenuContent>
<MenuList>
<MenuItem itemId="Deprecated">Deprecated</MenuItem>
<MenuItem itemId="Active">Active</MenuItem>
</MenuList>
</MenuContent>
</Menu>
),
[statusSelection, onStatusSelect],
);
const statusSelect = useMemo(
() => (
<div ref={statusContainerRef}>
<Popper
trigger={statusToggle}
triggerRef={statusToggleRef}
popper={statusMenu}
popperRef={statusMenuRef}
appendTo={statusContainerRef.current || undefined}
isVisible={isStatusMenuOpen}
/>
</div>
),
[statusToggle, statusMenu, isStatusMenuOpen],
<SelectList>
<SelectOption value="Deprecated">Deprecated</SelectOption>
<SelectOption value="Active">Active</SelectOption>
</SelectList>
</Select>
);
// Set up attribute selector
@ -311,108 +239,33 @@ export const WorkspaceKinds: React.FunctionComponent = () => {
'Name',
);
const [isAttributeMenuOpen, setIsAttributeMenuOpen] = useState(false);
const attributeToggleRef = useRef<HTMLButtonElement>(null);
const attributeMenuRef = useRef<HTMLDivElement>(null);
const attributeContainerRef = useRef<HTMLDivElement>(null);
const handleAttributeMenuKeys = useCallback(
(event: KeyboardEvent) => {
if (!isAttributeMenuOpen) {
return;
}
if (
attributeMenuRef.current?.contains(event.target as Node) ||
attributeToggleRef.current?.contains(event.target as Node)
) {
if (event.key === 'Escape' || event.key === 'Tab') {
setIsAttributeMenuOpen(!isAttributeMenuOpen);
attributeToggleRef.current?.focus();
}
}
},
[isAttributeMenuOpen],
);
const handleAttributeClickOutside = useCallback(
(event: MouseEvent) => {
if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) {
const attributeDropdown = (
<Select
isOpen={isAttributeMenuOpen}
selected={activeAttributeMenu}
onSelect={(_ev, itemId) => {
setActiveAttributeMenu(itemId?.toString() as 'Name' | 'Description' | 'Status');
setIsAttributeMenuOpen(false);
}
},
[isAttributeMenuOpen],
);
useEffect(() => {
window.addEventListener('keydown', handleAttributeMenuKeys);
window.addEventListener('click', handleAttributeClickOutside);
return () => {
window.removeEventListener('keydown', handleAttributeMenuKeys);
window.removeEventListener('click', handleAttributeClickOutside);
};
}, [isAttributeMenuOpen, attributeMenuRef, handleAttributeMenuKeys, handleAttributeClickOutside]);
const onAttributeToggleClick = useCallback((ev: React.MouseEvent) => {
ev.stopPropagation();
setTimeout(() => {
const firstElement = attributeMenuRef.current?.querySelector('li > button:not(:disabled)');
if (firstElement) {
(firstElement as HTMLElement).focus();
}
}, 0);
setIsAttributeMenuOpen((prev) => !prev);
}, []);
const attributeToggle = useMemo(
() => (
}}
onOpenChange={(isOpen) => setIsAttributeMenuOpen(isOpen)}
toggle={(toggleRef) => (
<MenuToggle
ref={attributeToggleRef}
onClick={onAttributeToggleClick}
ref={toggleRef}
onClick={() => setIsAttributeMenuOpen(!isAttributeMenuOpen)}
isExpanded={isAttributeMenuOpen}
icon={<FilterIcon />}
>
{activeAttributeMenu}
</MenuToggle>
),
[isAttributeMenuOpen, onAttributeToggleClick, activeAttributeMenu],
);
const attributeMenu = useMemo(
() => (
<Menu
ref={attributeMenuRef}
onSelect={(_ev, itemId) => {
setActiveAttributeMenu(itemId?.toString() as 'Name' | 'Description' | 'Status');
setIsAttributeMenuOpen((prev) => !prev);
}}
)}
>
<MenuContent>
<MenuList>
<MenuItem itemId="Name">Name</MenuItem>
<MenuItem itemId="Description">Description</MenuItem>
<MenuItem itemId="Status">Status</MenuItem>
</MenuList>
</MenuContent>
</Menu>
),
[],
);
const attributeDropdown = useMemo(
() => (
<div ref={attributeContainerRef}>
<Popper
trigger={attributeToggle}
triggerRef={attributeToggleRef}
popper={attributeMenu}
popperRef={attributeMenuRef}
appendTo={attributeContainerRef.current || undefined}
isVisible={isAttributeMenuOpen}
/>
</div>
),
[attributeToggle, attributeMenu, isAttributeMenuOpen],
<SelectList>
<SelectOption value="Name">Name</SelectOption>
<SelectOption value="Description">Description</SelectOption>
<SelectOption value="Status">Status</SelectOption>
</SelectList>
</Select>
);
const emptyState = useMemo(

View File

@ -5,10 +5,7 @@ import { Stack, StackItem } from '@patternfly/react-core/dist/esm/layouts/Stack'
import { Breadcrumb } from '@patternfly/react-core/dist/esm/components/Breadcrumb';
import { BreadcrumbItem } from '@patternfly/react-core/dist/esm/components/Breadcrumb/BreadcrumbItem';
import { useTypedLocation, useTypedParams } from '~/app/routerHelper';
import WorkspaceTable, {
WorkspaceTableFilteredColumn,
WorkspaceTableRef,
} from '~/app/components/WorkspaceTable';
import WorkspaceTable, { WorkspaceTableRef } from '~/app/components/WorkspaceTable';
import { useWorkspacesByKind } from '~/app/hooks/useWorkspaces';
import WorkspaceKindSummaryExpandableCard from '~/app/pages/WorkspaceKinds/summary/WorkspaceKindSummaryExpandableCard';
import { DEFAULT_POLLING_RATE_MS } from '~/app/const';
@ -37,11 +34,17 @@ const WorkspaceKindSummary: React.FC = () => {
const tableRowActions = useWorkspaceRowActions([{ id: 'viewDetails' }]);
const onAddFilter = useCallback(
(filter: WorkspaceTableFilteredColumn) => {
(columnKey: string, value: string) => {
if (!workspaceTableRef.current) {
return;
}
workspaceTableRef.current.addFilter(filter);
// Map to valid filter keys from WorkspaceTable
const validKeys = ['name', 'kind', 'image', 'state', 'namespace', 'idleGpu'] as const;
type ValidKey = (typeof validKeys)[number];
if (validKeys.includes(columnKey as ValidKey)) {
workspaceTableRef.current.setFilter(columnKey as ValidKey, value);
}
},
[workspaceTableRef],
);

View File

@ -20,7 +20,6 @@ import {
groupWorkspacesByNamespaceAndGpu,
YesNoValue,
} from '~/shared/utilities/WorkspaceUtils';
import { WorkspaceTableFilteredColumn } from '~/app/components/WorkspaceTable';
const TOP_GPU_CONSUMERS_LIMIT = 2;
@ -28,7 +27,7 @@ interface WorkspaceKindSummaryExpandableCardProps {
workspaces: Workspace[];
isExpanded: boolean;
onExpandToggle: () => void;
onAddFilter: (filter: WorkspaceTableFilteredColumn) => void;
onAddFilter: (columnKey: string, value: string) => void;
}
const WorkspaceKindSummaryExpandableCard: React.FC<WorkspaceKindSummaryExpandableCardProps> = ({
@ -73,7 +72,7 @@ const WorkspaceKindSummaryExpandableCard: React.FC<WorkspaceKindSummaryExpandabl
isInline
className="pf-v6-u-font-size-4xl pf-v6-u-font-weight-bold"
onClick={() => {
onAddFilter({ columnKey: 'idleGpu', value: YesNoValue.Yes });
onAddFilter('idleGpu', YesNoValue.Yes);
}}
>
{filterIdleWorkspacesWithGpu(workspaces).length}
@ -141,7 +140,7 @@ const SectionDivider: React.FC = () => (
interface NamespaceConsumerProps {
namespace: string;
gpuCount: number;
onAddFilter: (filter: WorkspaceTableFilteredColumn) => void;
onAddFilter: (columnKey: string, value: string) => void;
}
const NamespaceGpuConsumer: React.FC<NamespaceConsumerProps> = ({
@ -154,7 +153,7 @@ const NamespaceGpuConsumer: React.FC<NamespaceConsumerProps> = ({
variant="link"
isInline
onClick={() => {
onAddFilter({ columnKey: 'namespace', value: namespace });
onAddFilter('namespace', namespace);
}}
>
{namespace}

View File

@ -510,8 +510,8 @@
.mui-theme .pf-v6-c-menu {
--pf-v6-c-menu--BoxShadow: var(--mui-shadows-8);
--pf-v6-c-menu--BorderRadius: var(--mui-shape-borderRadius);
--pf-v6-c-menu--PaddingBlockStart: var(--mui-spacing);
--pf-v6-c-menu--PaddingBlockEnd: var(--mui-spacing);
--pf-v6-c-menu--PaddingBlockStart: none;
--pf-v6-c-menu--PaddingBlockEnd: none;
--pf-v6-c-menu--PaddingInlineStart: var(--mui-spacing);
--pf-v6-c-menu--PaddingInlineEnd: var(--mui-spacing);
--pf-v6-c-menu__item--PaddingBlockStart: var(--mui-menu__item--PaddingBlockStart);
@ -546,6 +546,8 @@
font-weight: var(--mui-button-font-weight);
letter-spacing: 0.02857em;
height: 37px;
}
.mui-theme .pf-v6-c-menu-toggle__button {
@ -919,7 +921,7 @@
margin-block-end: var(--mui-spacing-16px);
}
.workspacekind-file-upload {
.mui-theme .workspacekind-file-upload {
height: 100%;
.pf-v6-c-file-upload__file-details {
@ -928,7 +930,31 @@
}
/* Workaround for Toggle group header in Workspace Kind Form */
.workspace-kind-form-header .pf-v6-c-toggle-group__button.pf-m-selected {
.mui-theme .workspace-kind-form-header .pf-v6-c-toggle-group__button.pf-m-selected {
background-color: #e0f0ff;
color: var(--pf-t--color--black);
}
.mui-theme .pf-v6-c-menu__item {
&.pf-m-selected {
--pf-v6-c-menu__item--BackgroundColor: rgba(
var(--mui-palette-primary-mainChannel, 25 118 210) /
var(--mui-palette-action-selectedOpacity, 0.08)
);
--pf-v6-c-menu__item--FontWeight: var(--mui-button-font-weight);
.pf-v6-c-menu__item-select-icon {
visibility: hidden;
}
}
}
.mui-theme button.pf-v6-c-menu-toggle {
// Use box-shadow to create a border effect without affecting the layout
&.pf-m-expanded,
&:focus {
box-shadow: 0 0 0 2px var(--mui-palette-primary-main);
outline: none; // Remove default browser outline
}
}