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:
parent
bdbfe1bbd2
commit
7bed0beec1
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue