From e206ff14694add72193ed0023e6a4c6124cd4092 Mon Sep 17 00:00:00 2001 From: cyril-ui-developer Date: Thu, 18 Jun 2026 13:55:56 -0400 Subject: [PATCH] OCPBUGS-88739: Filter projects by display name in ConsoleDataView (cherry picked from commit 9795a132aab7624a5d92e1c0231f46c43ff5abb1) --- .../useConsoleDataViewFilters.spec.tsx | 321 ++++++++++++++++++ .../data-view/useConsoleDataViewFilters.ts | 13 +- 2 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 frontend/packages/console-app/src/components/data-view/__tests__/useConsoleDataViewFilters.spec.tsx 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..5b0607a590b --- /dev/null +++ b/frontend/packages/console-app/src/components/data-view/__tests__/useConsoleDataViewFilters.spec.tsx @@ -0,0 +1,321 @@ +import type { FC, ReactNode } from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { MemoryRouter } 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 projectMockData: K8sResourceCommon[] = [ + { + metadata: { + name: 'test-proj', + annotations: { 'openshift.io/display-name': 'My Test Project' }, + }, + kind: 'Project', + apiVersion: 'v1', + }, + { + metadata: { + name: 'other-proj', + annotations: { 'openshift.io/display-name': 'Other Project' }, + }, + kind: 'Project', + apiVersion: 'v1', + }, + { + metadata: { name: 'no-display-name' }, + kind: 'Project', + apiVersion: 'v1', + }, +]; + +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 openshift.io/display-name using fuzzy matching', () => { + const { result } = renderHook( + () => useConsoleDataViewFilters({ data: projectMockData, initialFilters }), + { wrapper: createWrapper(['/?name=My%20Test']) }, + ); + + expect(result.current.filteredData).toHaveLength(1); + expect(result.current.filteredData[0].metadata.name).toBe('test-proj'); + }); + + it('should filter by metadata.name when display name differs', () => { + const { result } = renderHook( + () => useConsoleDataViewFilters({ data: projectMockData, initialFilters }), + { wrapper: createWrapper(['/?name=test-proj']) }, + ); + + expect(result.current.filteredData).toHaveLength(1); + expect(result.current.filteredData[0].metadata.name).toBe('test-proj'); + }); + + it('should match project by display name case-insensitively in fuzzy mode', () => { + // Default fuzzy mode - no mock override + const { result } = renderHook( + () => useConsoleDataViewFilters({ data: projectMockData, initialFilters }), + { wrapper: createWrapper(['/?name=other%20project']) }, // lowercase search + ); + + // Matches "Other Project" even though we searched "other project" + expect(result.current.filteredData).toHaveLength(1); + expect(result.current.filteredData[0].metadata.name).toBe('other-proj'); + }); + + it('should not match resources without display-name annotation when searching by display name', () => { + const { result } = renderHook( + () => useConsoleDataViewFilters({ data: projectMockData, initialFilters }), + { wrapper: createWrapper(['/?name=Project']) }, + ); + + // "Project" appears in display names of test-proj and other-proj, but not in "no-display-name" + expect(result.current.filteredData).toHaveLength(2); + expect(result.current.filteredData.map((d) => d.metadata.name)).toEqual([ + 'test-proj', + 'other-proj', + ]); + }); + + it('should filter by openshift.io/display-name using exact matching when exact search is enabled', () => { + useExactSearch.mockReturnValue([true, true]); + + const { result } = renderHook( + () => useConsoleDataViewFilters({ data: projectMockData, initialFilters }), + { wrapper: createWrapper(['/?name=My%20Test%20Project']) }, + ); + + expect(result.current.filteredData).toHaveLength(1); + expect(result.current.filteredData[0].metadata.name).toBe('test-proj'); + }); + + it('should require case-sensitive match for display-name in exact search mode', () => { + useExactSearch.mockReturnValue([true, true]); + + const { result } = renderHook( + () => useConsoleDataViewFilters({ data: projectMockData, initialFilters }), + { wrapper: createWrapper(['/?name=my%20test']) }, + ); + + expect(result.current.filteredData).toHaveLength(0); + }); + + 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 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..0a00c64ebda 100644 --- a/frontend/packages/console-app/src/components/data-view/useConsoleDataViewFilters.ts +++ b/frontend/packages/console-app/src/components/data-view/useConsoleDataViewFilters.ts @@ -15,6 +15,9 @@ const getK8sResourceMetadata = (obj: K8sResourceCommon): ResourceMetadata => ({ labels: obj.metadata?.labels, }); +const getOpenShiftDisplayName = (resource: K8sResourceCommon): string | undefined => + resource.metadata?.annotations?.['openshift.io/display-name']; + export const useConsoleDataViewFilters = < TData, TFilters extends ResourceFilters = ResourceFilters @@ -42,12 +45,14 @@ export const useConsoleDataViewFilters = < () => data?.filter((resource) => { const { name: resourceName, labels } = getObjectMetadata(resource); + const displayName = getOpenShiftDisplayName(resource as K8sResourceCommon); - // Filter by K8s resource name + // Filter by K8s resource name or display name + const matchFn = isExactSearch ? exactMatch : fuzzyCaseInsensitive; const matchesName = - !filters.name || isExactSearch - ? exactMatch(filters.name, resourceName) - : fuzzyCaseInsensitive(filters.name, resourceName); + !filters.name || + matchFn(filters.name, resourceName) || + matchFn(filters.name, displayName); const resourceLabels = mapLabelsToStrings(labels); const filterLabelsArray = filters.label?.split(',') ?? [];