diff --git a/frontend/packages/console-app/src/components/data-view/__tests__/useConsoleDataViewFilters.spec.tsx b/frontend/packages/console-app/src/components/data-view/__tests__/useConsoleDataViewFilters.spec.tsx new file mode 100644 index 00000000000..7c62c94daa5 --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/__tests__/useConsoleDataViewFilters.spec.tsx @@ -0,0 +1,263 @@ +import type { FC, ReactNode } from 'react'; +import { render, renderHook, act } from '@testing-library/react'; +import { MemoryRouter, useNavigate } from 'react-router'; +import type { K8sResourceCommon } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; +import type { ResourceFilters } from '../types'; +import { useConsoleDataViewFilters } from '../useConsoleDataViewFilters'; + +jest.mock('@console/internal/components/factory/table-filters', () => ({ + exactMatch: (filter: string, value: string) => !filter || value?.includes(filter), + fuzzyCaseInsensitive: (filter: string, value: string) => + !filter || value?.toLowerCase().includes(filter.toLowerCase()), +})); + +jest.mock('@console/shared/src/utils/label-filter', () => ({ + mapLabelsToStrings: (labels: Record = {}) => + Object.entries(labels).map(([k, v]) => `${k}=${v}`), +})); + +jest.mock('@console/app/src/components/user-preferences/search/useExactSearch', () => ({ + useExactSearch: jest.fn(() => [false, true]), +})); + +const { useExactSearch } = jest.requireMock( + '@console/app/src/components/user-preferences/search/useExactSearch', +) as { useExactSearch: jest.Mock }; + +const mockData: K8sResourceCommon[] = [ + { metadata: { name: 'api-server', labels: { app: 'api' } }, kind: 'Pod', apiVersion: 'v1' }, + { + metadata: { name: 'web-frontend', labels: { app: 'web', tier: 'frontend' } }, + kind: 'Pod', + apiVersion: 'v1', + }, + { + metadata: { name: 'api-gateway', labels: { app: 'api', tier: 'gateway' } }, + kind: 'Pod', + apiVersion: 'v1', + }, +]; + +const initialFilters: ResourceFilters = { name: '', label: '' }; + +const createWrapper = (initialEntries: string[] = ['/']): FC<{ children: ReactNode }> => { + const Wrapper: FC<{ children: ReactNode }> = ({ children }) => ( + {children} + ); + Wrapper.displayName = 'MemoryRouterWrapper'; + return Wrapper; +}; + +describe('useConsoleDataViewFilters', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + useExactSearch.mockReturnValue([false, true]); + // Suppress React warning about render-phase updates from PF's useDataViewFilters + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((msg: string) => { + if (typeof msg === 'string' && msg.includes('Cannot update a component')) { + // noop + } + }); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + jest.restoreAllMocks(); + }); + + it('should return all data when no filters are set', () => { + const { result } = renderHook( + () => useConsoleDataViewFilters({ data: mockData, initialFilters }), + { wrapper: createWrapper() }, + ); + + expect(result.current.filters).toEqual({ name: '', label: '' }); + expect(result.current.filteredData).toHaveLength(3); + }); + + it('should initialize filters from URL search params on mount', () => { + const { result } = renderHook( + () => useConsoleDataViewFilters({ data: mockData, initialFilters }), + { wrapper: createWrapper(['/?name=api']) }, + ); + + expect(result.current.filters.name).toBe('api'); + expect(result.current.filteredData).toHaveLength(2); + expect(result.current.filteredData.map((d) => d.metadata.name)).toEqual([ + 'api-server', + 'api-gateway', + ]); + }); + + it('should filter by name using fuzzy matching by default', () => { + const { result } = renderHook( + () => useConsoleDataViewFilters({ data: mockData, initialFilters }), + { wrapper: createWrapper(['/?name=front']) }, + ); + + expect(result.current.filteredData).toHaveLength(1); + expect(result.current.filteredData[0].metadata.name).toBe('web-frontend'); + }); + + it('should filter by name using exact matching when exact search is enabled', () => { + useExactSearch.mockReturnValue([true, true]); + + const { result } = renderHook( + () => useConsoleDataViewFilters({ data: mockData, initialFilters }), + { wrapper: createWrapper(['/?name=api']) }, + ); + + expect(result.current.filteredData).toHaveLength(2); + expect(result.current.filteredData.map((d) => d.metadata.name)).toEqual([ + 'api-server', + 'api-gateway', + ]); + }); + + it('should filter by label', () => { + const { result } = renderHook( + () => useConsoleDataViewFilters({ data: mockData, initialFilters }), + { wrapper: createWrapper(['/?label=app%3Dapi']) }, + ); + + expect(result.current.filteredData).toHaveLength(2); + expect(result.current.filteredData.map((d) => d.metadata.name)).toEqual([ + 'api-server', + 'api-gateway', + ]); + }); + + it('should filter by both name and label simultaneously', () => { + const { result } = renderHook( + () => useConsoleDataViewFilters({ data: mockData, initialFilters }), + { wrapper: createWrapper(['/?name=gateway&label=app%3Dapi']) }, + ); + + expect(result.current.filteredData).toHaveLength(1); + expect(result.current.filteredData[0].metadata.name).toBe('api-gateway'); + }); + + it('should update filters and filteredData via onSetFilters', () => { + const { result } = renderHook( + () => useConsoleDataViewFilters({ data: mockData, initialFilters }), + { wrapper: createWrapper() }, + ); + + expect(result.current.filteredData).toHaveLength(3); + + act(() => { + result.current.onSetFilters({ name: 'web' } as ResourceFilters); + }); + + expect(result.current.filters.name).toBe('web'); + expect(result.current.filteredData).toHaveLength(1); + expect(result.current.filteredData[0].metadata.name).toBe('web-frontend'); + }); + + it('should clear all filters via clearAllFilters', () => { + const { result } = renderHook( + () => useConsoleDataViewFilters({ data: mockData, initialFilters }), + { wrapper: createWrapper(['/?name=api&label=app%3Dapi']) }, + ); + + expect(result.current.filteredData).toHaveLength(2); + + act(() => { + result.current.clearAllFilters(); + }); + + expect(result.current.filters.name).toBe(''); + expect(result.current.filters.label).toBe(''); + expect(result.current.filteredData).toHaveLength(3); + }); + + it('should sync filters when URL changes externally after mount', () => { + let hookResult: { + filters: ResourceFilters; + onSetFilters: (filters: ResourceFilters) => void; + clearAllFilters: () => void; + filteredData: K8sResourceCommon[]; + }; + let navigate: ReturnType; + + const TestComponent = () => { + hookResult = useConsoleDataViewFilters({ data: mockData, initialFilters }); + navigate = useNavigate(); + return null; + }; + + render( + + + , + ); + + expect(hookResult.filteredData).toHaveLength(3); + + act(() => { + navigate('/?name=api'); + }); + + expect(hookResult.filters.name).toBe('api'); + expect(hookResult.filteredData).toHaveLength(2); + expect(hookResult.filteredData.map((d) => d.metadata.name)).toEqual([ + 'api-server', + 'api-gateway', + ]); + }); + + it('should support custom getObjectMetadata', () => { + type CustomResource = { id: string; displayName: string }; + const customData: CustomResource[] = [ + { id: '1', displayName: 'Alpha' }, + { id: '2', displayName: 'Beta' }, + ]; + const getObjectMetadata = (obj: CustomResource) => ({ + name: obj.displayName, + labels: undefined, + }); + + const { result } = renderHook( + () => + useConsoleDataViewFilters({ + data: customData, + initialFilters, + getObjectMetadata, + }), + { wrapper: createWrapper(['/?name=alpha']) }, + ); + + expect(result.current.filteredData).toHaveLength(1); + expect((result.current.filteredData[0] as CustomResource).displayName).toBe('Alpha'); + }); + + it('should support matchesAdditionalFilters', () => { + const matchesAdditionalFilters = (_obj: K8sResourceCommon, filters: ResourceFilters) => + !filters.name || _obj.metadata.name.startsWith('api'); + + const { result } = renderHook( + () => + useConsoleDataViewFilters({ + data: mockData, + initialFilters, + matchesAdditionalFilters, + }), + { wrapper: createWrapper(['/?name=a']) }, + ); + + expect(result.current.filteredData).toHaveLength(2); + expect(result.current.filteredData.map((d) => d.metadata.name)).toEqual([ + 'api-server', + 'api-gateway', + ]); + }); + + it('should handle empty data array', () => { + const { result } = renderHook(() => useConsoleDataViewFilters({ data: [], initialFilters }), { + wrapper: createWrapper(['/?name=api']), + }); + + expect(result.current.filteredData).toHaveLength(0); + }); +}); diff --git a/frontend/packages/console-app/src/components/data-view/useConsoleDataViewFilters.ts b/frontend/packages/console-app/src/components/data-view/useConsoleDataViewFilters.ts index 531b22e94d8..19f2c6e5ae7 100644 --- a/frontend/packages/console-app/src/components/data-view/useConsoleDataViewFilters.ts +++ b/frontend/packages/console-app/src/components/data-view/useConsoleDataViewFilters.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useDataViewFilters } from '@patternfly/react-data-view'; import { useSearchParams } from 'react-router'; import { useExactSearch } from '@console/app/src/components/user-preferences/search/useExactSearch'; @@ -38,6 +38,39 @@ export const useConsoleDataViewFilters = < setSearchParams, }); + // Sync URL search params → internal filter state. + // useDataViewFilters only reads searchParams on mount (empty deps useEffect). + // This effect ensures filters stay in sync when the URL changes externally + // (e.g., the Search page updating query params without remounting). + const filtersRef = useRef(filters); + filtersRef.current = filters; + useEffect(() => { + const updates: Partial = {}; + let hasChanges = false; + for (const key of Object.keys(filtersRef.current)) { + const currentValue = filtersRef.current[key]; + if (Array.isArray(currentValue)) { + const urlValues = searchParams.getAll(key); + if ( + urlValues.length !== currentValue.length || + urlValues.some((v, i) => v !== currentValue[i]) + ) { + updates[key] = urlValues; + hasChanges = true; + } + } else { + const urlValue = searchParams.get(key) ?? ''; + if (urlValue !== currentValue) { + updates[key] = urlValue; + hasChanges = true; + } + } + } + if (hasChanges) { + onSetFilters(updates as TFilters); + } + }, [searchParams, onSetFilters]); + const filteredData = useMemo( () => data?.filter((resource) => { diff --git a/frontend/public/components/search.tsx b/frontend/public/components/search.tsx index cff0a84364d..175703cb46f 100644 --- a/frontend/public/components/search.tsx +++ b/frontend/public/components/search.tsx @@ -1,6 +1,6 @@ import * as _ from 'lodash'; import type { FC, MouseEvent } from 'react'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo, memo } from 'react'; import { DocumentTitle } from '@console/shared/src/components/document-title/DocumentTitle'; import { useDebounceCallback } from '@console/shared/src/hooks/useDebounceCallback'; import { useTranslation } from 'react-i18next'; @@ -51,7 +51,15 @@ import { useActivePerspective } from '@console/dynamic-plugin-sdk/src/perspectiv import { useActiveNamespace, useK8sModel } from '@console/dynamic-plugin-sdk/src/lib-core'; import { ALL_NAMESPACES_KEY } from '@console/shared/src/constants'; -const ResourceList = ({ kind, mock, namespace, selector, nameFilter }) => { +interface ResourceListProps { + kind: string; + mock: boolean; + namespace: string; + selector: any; + nameFilter: string; +} + +const ResourceList = memo(({ kind, mock, namespace, selector, nameFilter }) => { const { plural } = useParams<{ plural?: string }>(); const [kindObj] = useK8sModel(kind || plural); const resourceListPageExtensions = useExtensions(isResourceListPage); @@ -81,7 +89,7 @@ const ResourceList = ({ kind, mock, namespace, selector, nameFilter }) => { hideColumnManagement /> ); -}; +}); const SearchPage_: FC = (props) => { const { setQueryArgument, removeQueryArguments } = useQueryParamsMutator(); @@ -120,10 +128,12 @@ const SearchPage_: FC = (props) => { const validTags = _.reject(tags, (tag) => requirementFromString(tag) === undefined); setLabelFilter(validTags); setTypeaheadNameFilter(name || ''); + setDebouncedNameFilter(name || ''); }, [location.search]); const debouncedNameFilterCallback = useDebounceCallback((nameFilter: string) => { setDebouncedNameFilter(nameFilter); + setQueryArgument('name', nameFilter); }, 300); useEffect(() => { @@ -131,7 +141,7 @@ const SearchPage_: FC = (props) => { }, [typeaheadNameFilter, debouncedNameFilterCallback]); const updateSelectedItems = (selection: string) => { - const updateItems = selectedItems; + const updateItems = new Set(selectedItems); fireTelemetryEvent('search-resource-selected', { resource: selection, }); @@ -141,7 +151,7 @@ const SearchPage_: FC = (props) => { }; const updateNewItems = (_filter: string, { key }: ToolbarLabel) => { - const updateItems = selectedItems; + const updateItems = new Set(selectedItems); updateItems.has(key) ? updateItems.delete(key) : updateItems.add(key); setSelectedItems(updateItems); setQueryArgument('kind', [...updateItems].join(',')); @@ -154,6 +164,7 @@ const SearchPage_: FC = (props) => { const clearNameFilter = () => { setTypeaheadNameFilter(''); + setDebouncedNameFilter(''); setQueryArgument('name', ''); }; @@ -165,6 +176,7 @@ const SearchPage_: FC = (props) => { const clearAll = () => { setSelectedItems(new Set([])); setTypeaheadNameFilter(''); + setDebouncedNameFilter(''); setLabelFilter([]); removeQueryArguments('kind', 'name', 'q'); }; @@ -188,7 +200,6 @@ const SearchPage_: FC = (props) => { const updateNameFilter = (value: string) => { setTypeaheadNameFilter(value); - setQueryArgument('name', value); }; const updateLabelFilter = (value: string, endOfString: boolean) => { @@ -239,6 +250,8 @@ const SearchPage_: FC = (props) => { return model.labelKey ? t(model.labelKey) : model.label; }; + const selector = useMemo(() => selectorFromString(labelFilter.join(',')), [labelFilter]); + return ( <> {t('public~Search')} @@ -338,11 +351,11 @@ const SearchPage_: FC = (props) => { {!isCollapsed && ( )}