diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx index 9becc15e..9de966de 100644 --- a/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx @@ -21,11 +21,6 @@ import { ToolbarGroup, ToolbarFilter, ToolbarToggleGroup, - EmptyStateActions, - EmptyState, - EmptyStateFooter, - EmptyStateBody, - Button, Bullseye, } from '@patternfly/react-core'; import { @@ -39,8 +34,9 @@ import { ActionsColumn, IActions, } from '@patternfly/react-table'; -import { CodeIcon, FilterIcon, SearchIcon } from '@patternfly/react-icons'; +import { CodeIcon, FilterIcon } from '@patternfly/react-icons'; import { WorkspaceKind, WorkspaceKindsColumnNames } from '~/shared/types'; +import EmptyStateWithClearFilters from 'shared/components/EmptyStateWithClearFilters'; export enum ActionType { ViewDetails, @@ -245,6 +241,12 @@ export const WorkspaceKinds: React.FunctionComponent = () => { [sortedWorkspaceKinds, onFilter], ); + const clearAllFilters = React.useCallback(() => { + setSearchNameValue(''); + setStatusSelection(''); + setSearchDescriptionValue(''); + }, []); + // Set up name search input const searchNameInput = React.useMemo( () => ( @@ -490,27 +492,14 @@ export const WorkspaceKinds: React.FunctionComponent = () => { const emptyState = React.useMemo( () => ( - - - No results match the filter criteria. Clear all filters and try again. - - - - - - - + ), - [], + [clearAllFilters], ); // Actions @@ -549,14 +538,7 @@ export const WorkspaceKinds: React.FunctionComponent = () => {
- { - setSearchNameValue(''); - setStatusSelection(''); - setSearchDescriptionValue(''); - }} - > + } breakpoint="xl"> diff --git a/workspaces/frontend/src/app/pages/Workspaces/Creation/image/WorkspaceCreationImageList.tsx b/workspaces/frontend/src/app/pages/Workspaces/Creation/image/WorkspaceCreationImageList.tsx index 7b22b65c..ed7d5c14 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Creation/image/WorkspaceCreationImageList.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Creation/image/WorkspaceCreationImageList.tsx @@ -7,13 +7,11 @@ import { ToolbarContent, Card, CardHeader, - EmptyState, - EmptyStateBody, CardBody, } from '@patternfly/react-core'; -import { SearchIcon } from '@patternfly/react-icons/dist/esm/icons/search-icon'; import { WorkspaceImage } from '~/shared/types'; -import Filter, { FilteredColumn } from '~/shared/components/Filter'; +import Filter, { FilteredColumn, FilterRef } from '~/shared/components/Filter'; +import EmptyStateWithClearFilters from 'shared/components/EmptyStateWithClearFilters'; type WorkspaceCreationImageListProps = { images: WorkspaceImage[]; @@ -28,6 +26,8 @@ export const WorkspaceCreationImageList: React.FunctionComponent< const [workspaceImages, setWorkspaceImages] = useState(images); const [filters, setFilters] = useState([]); + const filterRef = React.useRef(null); + const filterableColumns = useMemo( () => ({ name: 'Name', @@ -35,6 +35,10 @@ export const WorkspaceCreationImageList: React.FunctionComponent< [], ); + const clearAllFilters = useCallback(() => { + filterRef.current?.clearAll(); + }, []); + const getFilteredWorkspaceImagesByLabels = useCallback( (unfilteredImages: WorkspaceImage[]) => unfilteredImages.filter((image) => @@ -97,6 +101,7 @@ export const WorkspaceCreationImageList: React.FunctionComponent< {workspaceImages.length === 0 && ( - - - No results match the filter criteria. Clear all filters and try again. - - + )} {workspaceImages.length > 0 && ( diff --git a/workspaces/frontend/src/app/pages/Workspaces/Creation/kind/WorkspaceCreationKindList.tsx b/workspaces/frontend/src/app/pages/Workspaces/Creation/kind/WorkspaceCreationKindList.tsx index 23ef90f8..8f3fa1fa 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Creation/kind/WorkspaceCreationKindList.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Creation/kind/WorkspaceCreationKindList.tsx @@ -8,12 +8,10 @@ import { ToolbarContent, Card, CardHeader, - EmptyState, - EmptyStateBody, } from '@patternfly/react-core'; -import { SearchIcon } from '@patternfly/react-icons/dist/esm/icons/search-icon'; import { WorkspaceKind } from '~/shared/types'; -import Filter, { FilteredColumn } from '~/shared/components/Filter'; +import Filter, { FilteredColumn, FilterRef } from '~/shared/components/Filter'; +import EmptyStateWithClearFilters from 'shared/components/EmptyStateWithClearFilters'; type WorkspaceCreationKindListProps = { allWorkspaceKinds: WorkspaceKind[]; @@ -28,6 +26,8 @@ export const WorkspaceCreationKindList: React.FunctionComponent { const [workspaceKinds, setWorkspaceKinds] = useState(allWorkspaceKinds); + const filterRef = React.useRef(null); + const filterableColumns = useMemo( () => ({ name: 'Name', @@ -67,6 +67,10 @@ export const WorkspaceCreationKindList: React.FunctionComponent { + filterRef.current?.clearAll(); + }, []); + const onChange = useCallback( (event: React.FormEvent) => { const newSelectedWorkspaceKind = workspaceKinds.find( @@ -83,7 +87,8 @@ export const WorkspaceCreationKindList: React.FunctionComponent @@ -92,11 +97,11 @@ export const WorkspaceCreationKindList: React.FunctionComponent {workspaceKinds.length === 0 && ( - - - No results match the filter criteria. Clear all filters and try again. - - + )} {workspaceKinds.length > 0 && ( diff --git a/workspaces/frontend/src/app/pages/Workspaces/Creation/podConfig/WorkspaceCreationPodConfigList.tsx b/workspaces/frontend/src/app/pages/Workspaces/Creation/podConfig/WorkspaceCreationPodConfigList.tsx index cb383f3e..a3c8d6e3 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Creation/podConfig/WorkspaceCreationPodConfigList.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Creation/podConfig/WorkspaceCreationPodConfigList.tsx @@ -7,13 +7,11 @@ import { ToolbarContent, Card, CardHeader, - EmptyState, - EmptyStateBody, CardBody, } from '@patternfly/react-core'; -import { SearchIcon } from '@patternfly/react-icons/dist/esm/icons/search-icon'; import { WorkspacePodConfig } from '~/shared/types'; -import Filter, { FilteredColumn } from '~/shared/components/Filter'; +import Filter, { FilteredColumn, FilterRef } from '~/shared/components/Filter'; +import EmptyStateWithClearFilters from 'shared/components/EmptyStateWithClearFilters'; type WorkspaceCreationPodConfigListProps = { podConfigs: WorkspacePodConfig[]; @@ -28,6 +26,8 @@ export const WorkspaceCreationPodConfigList: React.FunctionComponent< const [workspacePodConfigs, setWorkspacePodConfigs] = useState(podConfigs); const [filters, setFilters] = useState([]); + const filterRef = React.useRef(null); + const filterableColumns = useMemo( () => ({ name: 'Name', @@ -60,6 +60,10 @@ export const WorkspaceCreationPodConfigList: React.FunctionComponent< [workspacePodConfigs, onSelect], ); + const clearAllFilters = useCallback(() => { + filterRef.current?.clearAll(); + }, []); + useEffect(() => { // Search name with search value let filteredWorkspacePodConfigs = podConfigs; @@ -103,7 +107,8 @@ export const WorkspaceCreationPodConfigList: React.FunctionComponent< @@ -112,11 +117,11 @@ export const WorkspaceCreationPodConfigList: React.FunctionComponent< {workspacePodConfigs.length === 0 && ( - - - No results match the filter criteria. Clear all filters and try again. - - + )} {workspacePodConfigs.length > 0 && ( diff --git a/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx b/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx index e61217ad..76bb5f79 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx @@ -13,6 +13,7 @@ import { Content, Brand, Tooltip, + Bullseye, } from '@patternfly/react-core'; import { Table, @@ -47,7 +48,8 @@ import { WorkspaceConnectAction } from '~/app/pages/Workspaces/WorkspaceConnectA import { WorkspaceStartActionModal } from '~/app/pages/Workspaces/workspaceActions/WorkspaceStartActionModal'; import { WorkspaceRestartActionModal } from '~/app/pages/Workspaces/workspaceActions/WorkspaceRestartActionModal'; import { WorkspaceStopActionModal } from '~/app/pages/Workspaces/workspaceActions/WorkspaceStopActionModal'; -import Filter, { FilteredColumn } from 'shared/components/Filter'; +import EmptyStateWithClearFilters from '~/shared/components/EmptyStateWithClearFilters'; // Import the new component +import Filter, { FilteredColumn, FilterRef } from 'shared/components/Filter'; // Import FilterRef import { formatRam } from 'shared/utilities/WorkspaceUtils'; export enum ActionType { @@ -198,19 +200,21 @@ export const Workspaces: React.FunctionComponent = () => { > = {}; // Initialize the redirect status dictionary workspaceRedirectStatus = buildWorkspaceRedirectStatus(workspaceKinds); // Populate the dictionary - // Table columns - const columnNames: WorkspacesColumnNames = { - redirectStatus: 'Redirect Status', - name: 'Name', - kind: 'Kind', - image: 'Image', - podConfig: 'Pod Config', - state: 'State', - homeVol: 'Home Vol', - cpu: 'CPU', - ram: 'Memory', - lastActivity: 'Last Activity', - }; + const columnNames: WorkspacesColumnNames = React.useMemo( + () => ({ + redirectStatus: 'Redirect Status', + name: 'Name', + kind: 'Kind', + image: 'Image', + podConfig: 'Pod Config', + state: 'State', + homeVol: 'Home Vol', + cpu: 'CPU', + ram: 'Memory', + lastActivity: 'Last Activity', + }), + [], + ); const filterableColumns = { name: 'Name', @@ -231,6 +235,8 @@ export const Workspaces: React.FunctionComponent = () => { const [isActionAlertModalOpen, setIsActionAlertModalOpen] = React.useState(false); const [activeActionType, setActiveActionType] = React.useState(null); + const filterRef = React.useRef(null); + const selectWorkspace = React.useCallback( (newSelectedWorkspace: Workspace | null) => { if (selectedWorkspace?.name === newSelectedWorkspace?.name) { @@ -254,41 +260,56 @@ export const Workspaces: React.FunctionComponent = () => { expandedWorkspacesNames.includes(workspace.name); // filter function to pass to the filter component - const onFilter = (filters: FilteredColumn[]) => { - // Search name with search value - let filteredWorkspaces = initialWorkspaces; - filters.forEach((filter) => { - let searchValueInput: RegExp; - try { - searchValueInput = new RegExp(filter.value, 'i'); - } catch { - searchValueInput = new RegExp(filter.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); - } - - filteredWorkspaces = filteredWorkspaces.filter((workspace) => { - if (filter.value === '') { - return true; + const onFilter = React.useCallback( + (filters: FilteredColumn[]) => { + // Search name with search value + let filteredWorkspaces = initialWorkspaces; + filters.forEach((filter) => { + let searchValueInput: RegExp; + try { + searchValueInput = new RegExp(filter.value, 'i'); + } catch { + searchValueInput = new RegExp(filter.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); } - switch (filter.columnName) { - case columnNames.name: - return workspace.name.search(searchValueInput) >= 0; - case columnNames.kind: - return workspace.kind.search(searchValueInput) >= 0; - case columnNames.image: - return workspace.options.imageConfig.search(searchValueInput) >= 0; - case columnNames.podConfig: - return workspace.options.podConfig.search(searchValueInput) >= 0; - case columnNames.state: - return WorkspaceState[workspace.status.state].search(searchValueInput) >= 0; - case columnNames.homeVol: - return workspace.podTemplate.volumes.home.search(searchValueInput) >= 0; - default: + + filteredWorkspaces = filteredWorkspaces.filter((workspace) => { + if (filter.value === '') { return true; - } + } + switch (filter.columnName) { + case columnNames.name: + return workspace.name.search(searchValueInput) >= 0; + case columnNames.kind: + return workspace.kind.search(searchValueInput) >= 0; + case columnNames.image: + return workspace.options.imageConfig.search(searchValueInput) >= 0; + case columnNames.podConfig: + return workspace.options.podConfig.search(searchValueInput) >= 0; + case columnNames.state: + return WorkspaceState[workspace.status.state].search(searchValueInput) >= 0; + case columnNames.homeVol: + return workspace.podTemplate.volumes.home.search(searchValueInput) >= 0; + default: + return true; + } + }); }); - }); - setWorkspaces(filteredWorkspaces); - }; + setWorkspaces(filteredWorkspaces); + }, + [initialWorkspaces, columnNames], + ); + + const emptyState = React.useMemo( + () => ( + filterRef.current?.clearAll()} + colSpan={11} + /> + ), + [], + ); // Column sorting @@ -559,7 +580,12 @@ export const Workspaces: React.FunctionComponent = () => {
- + @@ -579,82 +605,90 @@ export const Workspaces: React.FunctionComponent = () => { - {sortedWorkspaces.map((workspace, rowIndex) => ( - - - - setWorkspaceExpanded(workspace, !isWorkspaceExpanded(workspace)), - }} - /> - - {workspaceRedirectStatus[workspace.kind] - ? getRedirectStatusIcon( - workspaceRedirectStatus[workspace.kind]?.level, - workspaceRedirectStatus[workspace.kind]?.message || - 'No API response available', - ) - : getRedirectStatusIcon(undefined, 'No API response available')} - - {workspace.name} - - {kindLogoDict[workspace.kind] ? ( - - - - ) : ( - - - - )} - - {workspace.options.imageConfig} - {workspace.options.podConfig} - - - - {workspace.podTemplate.volumes.home} - {`${workspace.cpu}%`} - {formatRam(workspace.ram)} - - - 1 hour ago - - - - - - - ({ - ...action, - 'data-testid': `action-${action.id || ''}`, - }))} + {sortedWorkspaces.length > 0 && + sortedWorkspaces.map((workspace, rowIndex) => ( + + + + setWorkspaceExpanded(workspace, !isWorkspaceExpanded(workspace)), + }} /> - - - {isWorkspaceExpanded(workspace) && ( - - )} - - ))} + + {workspaceRedirectStatus[workspace.kind] + ? getRedirectStatusIcon( + workspaceRedirectStatus[workspace.kind]?.level, + workspaceRedirectStatus[workspace.kind]?.message || + 'No API response available', + ) + : getRedirectStatusIcon(undefined, 'No API response available')} + + {workspace.name} + + {kindLogoDict[workspace.kind] ? ( + + + + ) : ( + + + + )} + + {workspace.options.imageConfig} + {workspace.options.podConfig} + + + + {workspace.podTemplate.volumes.home} + {`${workspace.cpu}%`} + {formatRam(workspace.ram)} + + + 1 hour ago + + + + + + + ({ + ...action, + 'data-testid': `action-${action.id || ''}`, + }))} + /> + + + {isWorkspaceExpanded(workspace) && ( + + )} + + ))} + {sortedWorkspaces.length === 0 && ( + + + {emptyState} + + + )} {isActionAlertModalOpen && chooseAlertModal()} void; + colSpan?: number; +} + +const EmptyStateWithClearFilters: React.FC = ({ + title, + body, + onClearFilters, + colSpan, +}) => + colSpan !== undefined ? ( + + + {body} + + + + + + + + ) : ( + + {body} + + + + + + + ); + +export default EmptyStateWithClearFilters; diff --git a/workspaces/frontend/src/shared/components/Filter.tsx b/workspaces/frontend/src/shared/components/Filter.tsx index 882b59c9..5bcf13fd 100644 --- a/workspaces/frontend/src/shared/components/Filter.tsx +++ b/workspaces/frontend/src/shared/components/Filter.tsx @@ -28,7 +28,14 @@ export interface FilteredColumn { value: string; } -const Filter: React.FC = ({ id, onFilter, columnNames }) => { +// Define the handle type that the parent will use to access the exposed function +export interface FilterRef { + clearAll: () => void; +} + +// Use forwardRef to allow parents to get a ref to this component instance +const Filter = React.forwardRef(({ id, onFilter, columnNames }, ref) => { + Filter.displayName = 'Filter'; const [activeFilter, setActiveFilter] = React.useState({ columnName: Object.values(columnNames)[0], value: '', @@ -80,65 +87,108 @@ const Filter: React.FC = ({ id, onFilter, columnNames }) => { const onFilterToggleClick = React.useCallback( (ev: React.MouseEvent) => { ev.stopPropagation(); // Stop handleClickOutside from handling - if (filterMenuRef.current) { - const firstElement = filterMenuRef.current.querySelector('li > button:not(:disabled)'); + setTimeout(() => { + const firstElement = filterMenuRef.current?.querySelector('li > button:not(:disabled)'); if (firstElement) { (firstElement as HTMLElement).focus(); } - } + }, 0); setIsFilterMenuOpen(!isFilterMenuOpen); }, [isFilterMenuOpen], ); - const addFilter = React.useCallback( + const updateFilters = React.useCallback( (filterObj: FilteredColumn) => { - const index = filters.findIndex((filter) => filter.columnName === filterObj.columnName); - const newFilters = filters; - if (index !== -1) { - newFilters[index] = filterObj; - } else { + setFilters((prevFilters) => { + const index = prevFilters.findIndex((filter) => filter.columnName === filterObj.columnName); + const newFilters = [...prevFilters]; + + if (filterObj.value === '') { + const updatedFilters = newFilters.filter( + (filter) => filter.columnName !== filterObj.columnName, + ); + onFilter(updatedFilters); + return updatedFilters; + } + if (index !== -1) { + newFilters[index] = filterObj; + onFilter(newFilters); + return newFilters; + } newFilters.push(filterObj); - } - setFilters(newFilters); + onFilter(newFilters); + return newFilters; + }); }, - [filters], + [onFilter], // onFilter is a dependency ); const onSearchChange = React.useCallback( (value: string) => { - const newFilter = { columnName: activeFilter.columnName, value }; setSearchValue(value); - setActiveFilter(newFilter); - addFilter(newFilter); - onFilter(filters); + setActiveFilter((prevActiveFilter) => { + const newActiveFilter = { ...prevActiveFilter, value }; + updateFilters(newActiveFilter); + return newActiveFilter; + }); }, - [activeFilter.columnName, addFilter, filters, onFilter], + [updateFilters], ); const onDeleteLabelGroup = React.useCallback( (filter: FilteredColumn) => { - const newFilters = filters.filter((filter1) => filter1.columnName !== filter.columnName); - setFilters(newFilters); + setFilters((prevFilters) => { + const newFilters = prevFilters.filter( + (filter1) => filter1.columnName !== filter.columnName, + ); + onFilter(newFilters); + return newFilters; + }); if (filter.columnName === activeFilter.columnName) { setSearchValue(''); + setActiveFilter((prevActiveFilter) => ({ + ...prevActiveFilter, + value: '', + })); } - onFilter(newFilters); }, - [activeFilter.columnName, filters, onFilter], + [activeFilter.columnName, onFilter], ); + // Expose the clearAllFilters logic via the ref + const clearAllInternal = React.useCallback(() => { + setFilters([]); + setSearchValue(''); + setActiveFilter({ + columnName: Object.values(columnNames)[0], + value: '', + }); + onFilter([]); + }, [columnNames, onFilter]); + + React.useImperativeHandle(ref, () => ({ + clearAll: clearAllInternal, + })); + const onFilterSelect = React.useCallback( (itemId: string | number | undefined) => { - setIsFilterMenuOpen(!isFilterMenuOpen); - const index = filters.findIndex((filter) => filter.columnName === itemId); - setSearchValue(index === -1 ? '' : filters[index].value); + // Use the functional update form to toggle the state + setIsFilterMenuOpen((prevIsMenuOpen) => !prevIsMenuOpen); // Fix is here + + const selectedColumnName = itemId ? itemId.toString() : Object.values(columnNames)[0]; + + // Find the existing filter value for the selected column, if any + const existingFilter = filters.find((filter) => filter.columnName === selectedColumnName); + const existingValue = existingFilter ? existingFilter.value : ''; + + setSearchValue(existingValue); // Set search input to the existing filter value setActiveFilter({ - columnName: itemId ? itemId.toString() : Object.values(columnNames)[0], - value: searchValue, + columnName: selectedColumnName, + value: existingValue, // Set the active filter value }); }, - [columnNames, filters, isFilterMenuOpen, searchValue], + [columnNames, filters], ); const filterMenuToggle = React.useMemo( @@ -191,11 +241,7 @@ const Filter: React.FC = ({ id, onFilter, columnNames }) => { return ( { - setFilters([]); - setSearchValue(''); - onFilter([]); - }} + clearAllFilters={clearAllInternal} // Use the internal clear function > } breakpoint="xl"> @@ -210,21 +256,24 @@ const Filter: React.FC = ({ id, onFilter, columnNames }) => { onClear={() => onSearchChange('')} /> - {filters.map((filter) => ( - onDeleteLabelGroup(filter)} - deleteLabelGroup={() => onDeleteLabelGroup(filter)} - categoryName={filter.columnName} - > - {undefined} - - ))} + {filters.map( + (filter) => + filter.value !== '' && ( + onDeleteLabelGroup(filter)} + deleteLabelGroup={() => onDeleteLabelGroup(filter)} + categoryName={filter.columnName} + > + {undefined} + + ), + )} ); -}; +}); export default Filter;