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 GitHub
parent 32484d6eae
commit 63600e8d9f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 328 additions and 324 deletions

View File

@ -16,13 +16,21 @@ describe('Application', () => {
cy.intercept('GET', '/api/v1/namespaces', { cy.intercept('GET', '/api/v1/namespaces', {
body: mockBFFResponse(mockNamespaces), body: mockBFFResponse(mockNamespaces),
}); });
cy.intercept('GET', '/api/v1/workspaces', {
body: mockBFFResponse(mockWorkspaces),
}).as('getWorkspaces');
cy.intercept('GET', '/api/v1/workspaces/default', { cy.intercept('GET', '/api/v1/workspaces/default', {
body: mockBFFResponse(mockWorkspaces), body: mockBFFResponse(mockWorkspaces),
}); });
cy.intercept('GET', '/api/namespaces/test-namespace/workspaces').as('getWorkspaces'); cy.intercept('GET', '/api/namespaces/test-namespace/workspaces').as('getWorkspaces');
}); });
it('filter rows with single filter', () => { it('filter rows with single filter', () => {
home.visit(); home.visit();
// Wait for the API call before trying to interact with the UI
cy.wait('@getWorkspaces');
useFilter('name', 'Name', 'My'); useFilter('name', 'Name', 'My');
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');

View File

@ -1,5 +1,4 @@
import React, { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'; import React, { useCallback, useImperativeHandle, useMemo, useState } from 'react';
import { PageSection } from '@patternfly/react-core/dist/esm/components/Page';
import { import {
TimestampTooltipVariant, TimestampTooltipVariant,
Timestamp, 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 { Bullseye } from '@patternfly/react-core/dist/esm/layouts/Bullseye';
import { Button } from '@patternfly/react-core/dist/esm/components/Button'; import { Button } from '@patternfly/react-core/dist/esm/components/Button';
import { Icon } from '@patternfly/react-core/dist/esm/components/Icon'; 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 { import {
Table, Table,
Thead, Thead,
@ -25,18 +38,14 @@ import {
ActionsColumn, ActionsColumn,
IActions, IActions,
} from '@patternfly/react-table/dist/esm/components/Table'; } 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 { InfoCircleIcon } from '@patternfly/react-icons/dist/esm/icons/info-circle-icon';
import { ExclamationTriangleIcon } from '@patternfly/react-icons/dist/esm/icons/exclamation-triangle-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 { TimesCircleIcon } from '@patternfly/react-icons/dist/esm/icons/times-circle-icon';
import { QuestionCircleIcon } from '@patternfly/react-icons/dist/esm/icons/question-circle-icon'; import { QuestionCircleIcon } from '@patternfly/react-icons/dist/esm/icons/question-circle-icon';
import { formatDistanceToNow } from 'date-fns/formatDistanceToNow'; import { formatDistanceToNow } from 'date-fns/formatDistanceToNow';
import { Workspace, WorkspaceState } from '~/shared/api/backendApiTypes'; import { Workspace, WorkspaceState } from '~/shared/api/backendApiTypes';
import { import { DataFieldKey, defineDataFields, SortableDataFieldKey } from '~/app/filterableDataHelper';
DataFieldKey,
defineDataFields,
FilterableDataFieldKey,
SortableDataFieldKey,
} from '~/app/filterableDataHelper';
import { useTypedNavigate } from '~/app/routerHelper'; import { useTypedNavigate } from '~/app/routerHelper';
import { import {
buildKindLogoDictionary, buildKindLogoDictionary,
@ -44,8 +53,7 @@ import {
} from '~/app/actions/WorkspaceKindsActions'; } from '~/app/actions/WorkspaceKindsActions';
import useWorkspaceKinds from '~/app/hooks/useWorkspaceKinds'; import useWorkspaceKinds from '~/app/hooks/useWorkspaceKinds';
import { WorkspaceConnectAction } from '~/app/pages/Workspaces/WorkspaceConnectAction'; import { WorkspaceConnectAction } from '~/app/pages/Workspaces/WorkspaceConnectAction';
import CustomEmptyState from '~/shared/components/CustomEmptyState'; import ThemeAwareSearchInput from '~/app/components/ThemeAwareSearchInput';
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 ImageFallback from '~/shared/components/ImageFallback';
import { import {
@ -53,12 +61,12 @@ import {
formatWorkspaceIdleState, formatWorkspaceIdleState,
} from '~/shared/utilities/WorkspaceUtils'; } from '~/shared/utilities/WorkspaceUtils';
import { ExpandedWorkspaceRow } from '~/app/pages/Workspaces/ExpandedWorkspaceRow'; import { ExpandedWorkspaceRow } from '~/app/pages/Workspaces/ExpandedWorkspaceRow';
import CustomEmptyState from '~/shared/components/CustomEmptyState';
const { const {
fields: wsTableColumns, fields: wsTableColumns,
keyArray: wsTableColumnKeyArray, keyArray: wsTableColumnKeyArray,
sortableKeyArray: sortableWsTableColumnKeyArray, sortableKeyArray: sortableWsTableColumnKeyArray,
filterableKeyArray: filterableWsTableColumnKeyArray,
} = defineDataFields({ } = defineDataFields({
name: { label: 'Name', isFilterable: true, isSortable: true, width: 35 }, name: { label: 'Name', isFilterable: true, isSortable: true, width: 35 },
image: { label: 'Image', isFilterable: true, isSortable: true, width: 25 }, image: { label: 'Image', isFilterable: true, isSortable: true, width: 25 },
@ -73,21 +81,40 @@ const {
}); });
export type WorkspaceTableColumnKeys = DataFieldKey<typeof wsTableColumns>; export type WorkspaceTableColumnKeys = DataFieldKey<typeof wsTableColumns>;
type WorkspaceTableFilterableColumnKeys = FilterableDataFieldKey<typeof wsTableColumns>;
type WorkspaceTableSortableColumnKeys = SortableDataFieldKey<typeof wsTableColumns>; type WorkspaceTableSortableColumnKeys = SortableDataFieldKey<typeof wsTableColumns>;
export type WorkspaceTableFilteredColumn = FilteredColumn<WorkspaceTableFilterableColumnKeys>;
interface WorkspaceTableProps { interface WorkspaceTableProps {
workspaces: Workspace[]; workspaces: Workspace[];
canCreateWorkspaces?: boolean; canCreateWorkspaces?: boolean;
canExpandRows?: boolean; canExpandRows?: boolean;
initialFilters?: WorkspaceTableFilteredColumn[];
hiddenColumns?: WorkspaceTableColumnKeys[]; hiddenColumns?: WorkspaceTableColumnKeys[];
rowActions?: (workspace: Workspace) => IActions; 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 { export interface WorkspaceTableRef {
addFilter: (filter: WorkspaceTableFilteredColumn) => void; clearAllFilters: () => void;
setFilter: (key: FilterKey, value: string) => void;
} }
const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>( const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
@ -96,7 +123,6 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
workspaces, workspaces,
canCreateWorkspaces = true, canCreateWorkspaces = true,
canExpandRows = true, canExpandRows = true,
initialFilters = [],
hiddenColumns = [], hiddenColumns = [],
rowActions = () => [], rowActions = () => [],
}, },
@ -104,7 +130,15 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
) => { ) => {
const [workspaceKinds] = useWorkspaceKinds(); const [workspaceKinds] = useWorkspaceKinds();
const [expandedWorkspacesNames, setExpandedWorkspacesNames] = useState<string[]>([]); 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] = const [activeSortColumnKey, setActiveSortColumnKey] =
useState<WorkspaceTableSortableColumnKeys | null>(null); useState<WorkspaceTableSortableColumnKeys | null>(null);
const [activeSortDirection, setActiveSortDirection] = useState<'asc' | 'desc' | 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 [perPage, setPerPage] = useState(10);
const navigate = useTypedNavigate(); const navigate = useTypedNavigate();
const filterRef = useRef<FilterRef>(null);
const kindLogoDict = buildKindLogoDictionary(workspaceKinds); const kindLogoDict = buildKindLogoDictionary(workspaceKinds);
const workspaceRedirectStatus = buildWorkspaceRedirectStatus(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( const visibleColumnKeys: WorkspaceTableColumnKeys[] = useMemo(
() => () =>
hiddenColumns.length hiddenColumns.length
@ -129,39 +219,32 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
[visibleColumnKeys], [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, () => ({ useImperativeHandle(ref, () => ({
addFilter: (newFilter: WorkspaceTableFilteredColumn) => { clearAllFilters,
if (!visibleFilterableColumnKeys.includes(newFilter.columnKey)) { setFilter: handleFilterChange,
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];
});
},
})); }));
const createWorkspace = useCallback(() => { const createWorkspace = useCallback(() => {
navigate('workspaceCreate'); navigate('workspaceCreate');
}, [navigate]); }, [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) => const setWorkspaceExpanded = (workspace: Workspace, isExpanding = true) =>
setExpandedWorkspacesNames((prevExpanded) => { setExpandedWorkspacesNames((prevExpanded) => {
const newExpandedWorkspacesNames = prevExpanded.filter( const newExpandedWorkspacesNames = prevExpanded.filter(
@ -179,37 +262,29 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
if (workspaces.length === 0) { if (workspaces.length === 0) {
return []; return [];
} }
const testRegex = (value: string, searchValue: string) => {
return filters.reduce((result, filter) => { if (!searchValue) {
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:
return true; return true;
} }
}); try {
}, workspaces); return new RegExp(searchValue, 'i').test(value);
}, [workspaces, filters]); } 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 // Column sorting
@ -317,8 +392,6 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
} }
}; };
// Redirect Status Icons
const getRedirectStatusIcon = (level: string | undefined, message: string) => { const getRedirectStatusIcon = (level: string | undefined, message: string) => {
switch (level) { switch (level) {
case 'Info': case 'Info':
@ -383,22 +456,68 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
}; };
return ( return (
<PageSection isFilled> <>
<Content style={{ display: 'flex', alignItems: 'flex-start', columnGap: '20px' }}> <Content style={{ display: 'flex', alignItems: 'flex-start', columnGap: '20px' }}>
<Filter <Toolbar id="workspace-filter-toolbar" clearAllFilters={clearAllFilters}>
ref={filterRef} <ToolbarContent>
id="filter-workspaces" <ToolbarToggleGroup toggleIcon={<FilterIcon />} breakpoint="xl">
filters={filters} <ToolbarGroup variant="filter-group">
setFilters={setFilters} <ToolbarItem>{attributeDropdown}</ToolbarItem>
columnDefinition={visibleFilterableColumnMap} {filterConfigs.map(({ key, label, placeholder }) => (
toolbarActions={ <ToolbarFilter
canCreateWorkspaces && ( key={key}
<Button variant="primary" ouiaId="Primary" onClick={createWorkspace}> 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 Create workspace
</Button> </Button>
) </ToolbarItem>
} )}
/> </ToolbarGroup>
</ToolbarToggleGroup>
</ToolbarContent>
</Toolbar>
</Content> </Content>
<Table <Table
data-testid="workspaces-table" data-testid="workspaces-table"
@ -552,15 +671,11 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
</Tbody> </Tbody>
))} ))}
{sortedWorkspaces.length === 0 && ( {sortedWorkspaces.length === 0 && (
<Tbody>
<Tr> <Tr>
<Td colSpan={12} id="empty-state-cell"> <Td colSpan={8} id="empty-state-cell">
<Bullseye> <Bullseye>{emptyState}</Bullseye>
<CustomEmptyState onClearFilters={() => filterRef.current?.clearAll()} />
</Bullseye>
</Td> </Td>
</Tr> </Tr>
</Tbody>
)} )}
</Table> </Table>
<Pagination <Pagination
@ -573,7 +688,7 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
onSetPage={onSetPage} onSetPage={onSetPage}
onPerPageSelect={onPerPageSelect} 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 { import {
Drawer, Drawer,
DrawerContent, DrawerContent,
@ -17,13 +17,11 @@ import {
ToolbarToggleGroup, ToolbarToggleGroup,
} from '@patternfly/react-core/dist/esm/components/Toolbar'; } from '@patternfly/react-core/dist/esm/components/Toolbar';
import { import {
Menu, Select,
MenuContent, SelectList,
MenuList, SelectOption,
MenuItem, } from '@patternfly/react-core/dist/esm/components/Select';
} from '@patternfly/react-core/dist/esm/components/Menu';
import { MenuToggle } from '@patternfly/react-core/dist/esm/components/MenuToggle'; 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 { Bullseye } from '@patternfly/react-core/dist/esm/layouts/Bullseye';
import { Button } from '@patternfly/react-core/dist/esm/components/Button'; import { Button } from '@patternfly/react-core/dist/esm/components/Button';
import { import {
@ -200,110 +198,40 @@ export const WorkspaceKinds: React.FunctionComponent = () => {
// Set up status single select // Set up status single select
const [isStatusMenuOpen, setIsStatusMenuOpen] = useState<boolean>(false); const [isStatusMenuOpen, setIsStatusMenuOpen] = useState<boolean>(false);
const statusToggleRef = useRef<HTMLButtonElement>(null);
const statusMenuRef = useRef<HTMLDivElement>(null);
const statusContainerRef = useRef<HTMLDivElement>(null);
const handleStatusMenuKeys = useCallback( const onStatusSelect = (
(event: KeyboardEvent) => { _event: React.MouseEvent | undefined,
if (isStatusMenuOpen && statusMenuRef.current?.contains(event.target as Node)) { value: string | number | undefined,
if (event.key === 'Escape' || event.key === 'Tab') { ) => {
setIsStatusMenuOpen(!isStatusMenuOpen); if (typeof value === 'undefined') {
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') {
return; return;
} }
setStatusSelection(value.toString());
setIsStatusMenuOpen(false);
};
setStatusSelection(itemId.toString()); const statusSelect = (
setIsStatusMenuOpen((prev) => !prev); <Select
}, isOpen={isStatusMenuOpen}
[], selected={statusSelection}
); onSelect={onStatusSelect}
onOpenChange={(isOpen) => setIsStatusMenuOpen(isOpen)}
const statusToggle = useMemo( toggle={(toggleRef) => (
() => (
<MenuToggle <MenuToggle
ref={statusToggleRef} ref={toggleRef}
onClick={onStatusToggleClick} onClick={() => setIsStatusMenuOpen(!isStatusMenuOpen)}
isExpanded={isStatusMenuOpen} isExpanded={isStatusMenuOpen}
style={{ width: '200px' } as React.CSSProperties} style={{ width: '200px' } as React.CSSProperties}
> >
Filter by status Filter by status
</MenuToggle> </MenuToggle>
), )}
[isStatusMenuOpen, onStatusToggleClick],
);
const statusMenu = useMemo(
() => (
<Menu
ref={statusMenuRef}
id="attribute-search-status-menu"
onSelect={onStatusSelect}
selected={statusSelection}
> >
<MenuContent> <SelectList>
<MenuList> <SelectOption value="Deprecated">Deprecated</SelectOption>
<MenuItem itemId="Deprecated">Deprecated</MenuItem> <SelectOption value="Active">Active</SelectOption>
<MenuItem itemId="Active">Active</MenuItem> </SelectList>
</MenuList> </Select>
</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],
); );
// Set up attribute selector // Set up attribute selector
@ -311,108 +239,33 @@ export const WorkspaceKinds: React.FunctionComponent = () => {
'Name', 'Name',
); );
const [isAttributeMenuOpen, setIsAttributeMenuOpen] = useState(false); const [isAttributeMenuOpen, setIsAttributeMenuOpen] = useState(false);
const attributeToggleRef = useRef<HTMLButtonElement>(null);
const attributeMenuRef = useRef<HTMLDivElement>(null);
const attributeContainerRef = useRef<HTMLDivElement>(null);
const handleAttributeMenuKeys = useCallback( const attributeDropdown = (
(event: KeyboardEvent) => { <Select
if (!isAttributeMenuOpen) { isOpen={isAttributeMenuOpen}
return; selected={activeAttributeMenu}
} onSelect={(_ev, itemId) => {
if ( setActiveAttributeMenu(itemId?.toString() as 'Name' | 'Description' | 'Status');
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)) {
setIsAttributeMenuOpen(false); setIsAttributeMenuOpen(false);
} }}
}, onOpenChange={(isOpen) => setIsAttributeMenuOpen(isOpen)}
[isAttributeMenuOpen], toggle={(toggleRef) => (
);
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(
() => (
<MenuToggle <MenuToggle
ref={attributeToggleRef} ref={toggleRef}
onClick={onAttributeToggleClick} onClick={() => setIsAttributeMenuOpen(!isAttributeMenuOpen)}
isExpanded={isAttributeMenuOpen} isExpanded={isAttributeMenuOpen}
icon={<FilterIcon />} icon={<FilterIcon />}
> >
{activeAttributeMenu} {activeAttributeMenu}
</MenuToggle> </MenuToggle>
), )}
[isAttributeMenuOpen, onAttributeToggleClick, activeAttributeMenu],
);
const attributeMenu = useMemo(
() => (
<Menu
ref={attributeMenuRef}
onSelect={(_ev, itemId) => {
setActiveAttributeMenu(itemId?.toString() as 'Name' | 'Description' | 'Status');
setIsAttributeMenuOpen((prev) => !prev);
}}
> >
<MenuContent> <SelectList>
<MenuList> <SelectOption value="Name">Name</SelectOption>
<MenuItem itemId="Name">Name</MenuItem> <SelectOption value="Description">Description</SelectOption>
<MenuItem itemId="Description">Description</MenuItem> <SelectOption value="Status">Status</SelectOption>
<MenuItem itemId="Status">Status</MenuItem> </SelectList>
</MenuList> </Select>
</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],
); );
const emptyState = useMemo( 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 { Breadcrumb } from '@patternfly/react-core/dist/esm/components/Breadcrumb';
import { BreadcrumbItem } from '@patternfly/react-core/dist/esm/components/Breadcrumb/BreadcrumbItem'; import { BreadcrumbItem } from '@patternfly/react-core/dist/esm/components/Breadcrumb/BreadcrumbItem';
import { useTypedLocation, useTypedParams } from '~/app/routerHelper'; import { useTypedLocation, useTypedParams } from '~/app/routerHelper';
import WorkspaceTable, { import WorkspaceTable, { WorkspaceTableRef } from '~/app/components/WorkspaceTable';
WorkspaceTableFilteredColumn,
WorkspaceTableRef,
} from '~/app/components/WorkspaceTable';
import { useWorkspacesByKind } from '~/app/hooks/useWorkspaces'; import { useWorkspacesByKind } from '~/app/hooks/useWorkspaces';
import WorkspaceKindSummaryExpandableCard from '~/app/pages/WorkspaceKinds/summary/WorkspaceKindSummaryExpandableCard'; import WorkspaceKindSummaryExpandableCard from '~/app/pages/WorkspaceKinds/summary/WorkspaceKindSummaryExpandableCard';
import { DEFAULT_POLLING_RATE_MS } from '~/app/const'; import { DEFAULT_POLLING_RATE_MS } from '~/app/const';
@ -37,11 +34,17 @@ const WorkspaceKindSummary: React.FC = () => {
const tableRowActions = useWorkspaceRowActions([{ id: 'viewDetails' }]); const tableRowActions = useWorkspaceRowActions([{ id: 'viewDetails' }]);
const onAddFilter = useCallback( const onAddFilter = useCallback(
(filter: WorkspaceTableFilteredColumn) => { (columnKey: string, value: string) => {
if (!workspaceTableRef.current) { if (!workspaceTableRef.current) {
return; 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], [workspaceTableRef],
); );

View File

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

View File

@ -510,8 +510,8 @@
.mui-theme .pf-v6-c-menu { .mui-theme .pf-v6-c-menu {
--pf-v6-c-menu--BoxShadow: var(--mui-shadows-8); --pf-v6-c-menu--BoxShadow: var(--mui-shadows-8);
--pf-v6-c-menu--BorderRadius: var(--mui-shape-borderRadius); --pf-v6-c-menu--BorderRadius: var(--mui-shape-borderRadius);
--pf-v6-c-menu--PaddingBlockStart: var(--mui-spacing); --pf-v6-c-menu--PaddingBlockStart: none;
--pf-v6-c-menu--PaddingBlockEnd: var(--mui-spacing); --pf-v6-c-menu--PaddingBlockEnd: none;
--pf-v6-c-menu--PaddingInlineStart: var(--mui-spacing); --pf-v6-c-menu--PaddingInlineStart: var(--mui-spacing);
--pf-v6-c-menu--PaddingInlineEnd: var(--mui-spacing); --pf-v6-c-menu--PaddingInlineEnd: var(--mui-spacing);
--pf-v6-c-menu__item--PaddingBlockStart: var(--mui-menu__item--PaddingBlockStart); --pf-v6-c-menu__item--PaddingBlockStart: var(--mui-menu__item--PaddingBlockStart);
@ -546,6 +546,8 @@
font-weight: var(--mui-button-font-weight); font-weight: var(--mui-button-font-weight);
letter-spacing: 0.02857em; letter-spacing: 0.02857em;
height: 37px;
} }
.mui-theme .pf-v6-c-menu-toggle__button { .mui-theme .pf-v6-c-menu-toggle__button {
@ -919,7 +921,7 @@
margin-block-end: var(--mui-spacing-16px); margin-block-end: var(--mui-spacing-16px);
} }
.workspacekind-file-upload { .mui-theme .workspacekind-file-upload {
height: 100%; height: 100%;
.pf-v6-c-file-upload__file-details { .pf-v6-c-file-upload__file-details {
@ -928,7 +930,31 @@
} }
/* Workaround for Toggle group header in Workspace Kind Form */ /* 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; background-color: #e0f0ff;
color: var(--pf-t--color--black); 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
}
}