diff --git a/.changeset/clever-chicken-jog.md b/.changeset/clever-chicken-jog.md new file mode 100644 index 0000000000..de65947105 --- /dev/null +++ b/.changeset/clever-chicken-jog.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +Added support for server side text filtering to paginated entity requests. diff --git a/.changeset/long-stingrays-look.md b/.changeset/long-stingrays-look.md new file mode 100644 index 0000000000..f1df12169e --- /dev/null +++ b/.changeset/long-stingrays-look.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': minor +--- + +Updated the paginated catalog table to support server side text filtering. diff --git a/.changeset/purple-camels-applaud.md b/.changeset/purple-camels-applaud.md new file mode 100644 index 0000000000..4f6858922a --- /dev/null +++ b/.changeset/purple-camels-applaud.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Fixed a bug where `fullTextFilter` wasn't preserved correctly in the cursor. diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index e08d3b1676..3eb00c5bd6 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -38,7 +38,7 @@ import { import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common/alpha'; import { CatalogProcessingOrchestrator } from '../processing/types'; import { z } from 'zod'; -import { encodeCursor } from './util'; +import { decodeCursor, encodeCursor } from './util'; import { wrapInOpenApiTestServer } from '@backstage/backend-openapi-utils'; import { Server } from 'http'; @@ -266,6 +266,47 @@ describe('createRouter readonly disabled', () => { }); }); + it('parses cursor request with fullTextFilter', async () => { + const items: Entity[] = [ + { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, + ]; + + entitiesCatalog.queryEntities.mockResolvedValueOnce({ + items, + totalItems: 100, + pageInfo: { + nextCursor: mockCursor({ fullTextFilter: { term: 'mySearch' } }), + }, + }); + + const cursor = mockCursor({ + totalItems: 100, + isPrevious: false, + fullTextFilter: { term: 'mySearch' }, + }); + + const response = await request(app).get( + `/entities/by-query?cursor=${encodeCursor(cursor)}`, + ); + expect(entitiesCatalog.queryEntities).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith({ + cursor, + }); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + items, + totalItems: 100, + pageInfo: { nextCursor: expect.any(String) }, + }); + const decodedCursor = decodeCursor(response.body.pageInfo.nextCursor); + expect(decodedCursor).toMatchObject({ + isPrevious: false, + fullTextFilter: { + term: 'mySearch', + }, + }); + }); + it('should throw in case of malformed cursor', async () => { const items: Entity[] = [ { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, diff --git a/plugins/catalog-backend/src/service/util.ts b/plugins/catalog-backend/src/service/util.ts index af1279a59a..4b041d1a70 100644 --- a/plugins/catalog-backend/src/service/util.ts +++ b/plugins/catalog-backend/src/service/util.ts @@ -106,6 +106,12 @@ export const cursorParser: z.ZodSchema = z.object({ orderFields: z.array( z.object({ field: z.string(), order: z.enum(['asc', 'desc']) }), ), + fullTextFilter: z + .object({ + term: z.string(), + fields: z.array(z.string()).optional(), + }) + .optional(), orderFieldValues: z.array(z.string().or(z.null())), filter: entityFilterParser.optional(), isPrevious: z.boolean(), diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 8c6b3a729f..62bdf3f2c9 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -541,6 +541,11 @@ export class EntityTextFilter implements EntityFilter { // (undocumented) filterEntity(entity: Entity): boolean; // (undocumented) + getFullTextFilters(): { + term: string; + fields: string[]; + }; + // (undocumented) readonly value: string; } diff --git a/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.ts b/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.ts index ba971b088d..6916d914f7 100644 --- a/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.ts +++ b/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.ts @@ -30,13 +30,13 @@ export function useAllEntitiesCount() { const request = useMemo(() => { const { user, ...allFilters } = filters; const compacted = compact(Object.values(allFilters)); - const filter = reduceCatalogFilters(compacted); + const catalogFilters = reduceCatalogFilters(compacted); const newRequest: QueryEntitiesInitialRequest = { - filter, + ...catalogFilters, limit: 0, }; - if (Object.keys(filter).length === 0) { + if (Object.keys(catalogFilters.filter).length === 0) { prevRequest.current = undefined; return prevRequest.current; } diff --git a/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.ts b/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.ts index 2f03a0b285..833eeb898f 100644 --- a/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.ts +++ b/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.ts @@ -21,7 +21,7 @@ import useAsync from 'react-use/lib/useAsync'; import { catalogApiRef } from '../../api'; import { EntityOwnerFilter, EntityUserFilter } from '../../filters'; import { useEntityList } from '../../hooks'; -import { reduceCatalogFilters } from '../../utils'; +import { CatalogFilters, reduceCatalogFilters } from '../../utils'; import useAsyncFn from 'react-use/lib/useAsyncFn'; import useDeepCompareEffect from 'react-use/lib/useDeepCompareEffect'; @@ -38,7 +38,7 @@ export function useOwnedEntitiesCount() { ); const { user, owners, ...allFilters } = filters; - const { ['metadata.name']: metadata, ...filter } = reduceCatalogFilters( + const catalogFilters = reduceCatalogFilters( compact(Object.values(allFilters)), ); @@ -47,7 +47,7 @@ export function useOwnedEntitiesCount() { async (req: { ownershipEntityRefs: string[]; owners: EntityOwnerFilter | undefined; - filter: Record; + filter: CatalogFilters; }) => { const ownedClaims = getOwnedCountClaims( req.owners, @@ -60,9 +60,12 @@ export function useOwnedEntitiesCount() { return 0; } + const { ['metadata.name']: metadata, ...filter } = req.filter.filter; + const { totalItems } = await catalogApi.queryEntities({ + ...req.filter, filter: { - ...req.filter, + ...filter, 'relations.ownedBy': ownedClaims, }, limit: 0, @@ -75,15 +78,19 @@ export function useOwnedEntitiesCount() { useDeepCompareEffect(() => { // context contains no filter, wait - if (Object.keys(filter).length === 0) { + if (Object.keys(catalogFilters.filter).length === 0) { return; } // ownershipEntityRefs is loading, wait if (ownershipEntityRefs === undefined) { return; } - fetchEntities({ ownershipEntityRefs, owners, filter }); - }, [ownershipEntityRefs, owners, filter]); + fetchEntities({ + ownershipEntityRefs, + owners, + filter: catalogFilters, + }); + }, [ownershipEntityRefs, owners, catalogFilters]); const loading = loadingEntityRefs || loadingEntityOwnership; diff --git a/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.ts b/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.ts index f7b2b101f3..66c0dd97e5 100644 --- a/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.ts +++ b/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.ts @@ -34,13 +34,14 @@ export function useStarredEntitiesCount() { const request = useMemo(() => { const { user, ...allFilters } = filters; const compacted = compact(Object.values(allFilters)); - const filter = reduceCatalogFilters(compacted); + const catalogFilters = reduceCatalogFilters(compacted); const facet = 'metadata.name'; const newRequest: QueryEntitiesInitialRequest = { + ...catalogFilters, filter: { - ...filter, + ...catalogFilters.filter, /** * here we are filtering entities by `name`. Given this filter, * the response might contain more entities than expected, in case multiple entities diff --git a/plugins/catalog-react/src/filters.ts b/plugins/catalog-react/src/filters.ts index 55983d124b..dda13338a7 100644 --- a/plugins/catalog-react/src/filters.ts +++ b/plugins/catalog-react/src/filters.ts @@ -108,6 +108,14 @@ export class EntityTextFilter implements EntityFilter { return true; } + getFullTextFilters() { + return { + term: this.value, + // Update this to be more dynamic based on table columns. + fields: ['metadata.name', 'metadata.title', 'spec.profile.displayName'], + }; + } + private toUpperArray( value: Array, ): Array { diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index f6881d4a80..c2328a1aef 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -33,6 +33,7 @@ import { catalogApiRef } from '../api'; import { starredEntitiesApiRef, MockStarredEntitiesApi } from '../apis'; import { EntityKindFilter, + EntityTextFilter, EntityTypeFilter, EntityUserFilter, } from '../filters'; @@ -171,6 +172,30 @@ describe('', () => { }); }); + it('ignores search text when not paginating', async () => { + const { result } = renderHook(() => useEntityList(), { + wrapper: createWrapper({ pagination }), + initialProps: { + userFilter: 'all', + }, + }); + + act(() => + result.current.updateFilters({ + text: new EntityTextFilter('1'), + }), + ); + + await waitFor(() => { + expect(result.current.backendEntities.length).toBe(2); + expect(result.current.entities.length).toBe(1); + expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1); + expect(mockCatalogApi.getEntities).toHaveBeenCalledWith({ + filter: { kind: 'component' }, + }); + }); + }); + it('resolves query param filter values', async () => { const query = qs.stringify({ filters: { kind: 'component', type: 'service' }, @@ -289,6 +314,40 @@ describe('', () => { jest.clearAllMocks(); }); + it('sends search text to the backend', async () => { + const { result } = renderHook(() => useEntityList(), { + wrapper: createWrapper({ pagination }), + initialProps: { + userFilter: 'all', + }, + }); + + act(() => + result.current.updateFilters({ + text: new EntityTextFilter('2'), + }), + ); + + await waitFor(() => { + expect(mockCatalogApi.getEntities).not.toHaveBeenCalledTimes(1); + expect(result.current.entities.length).toBe(1); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { kind: 'component' }, + limit, + orderFields, + fullTextFilter: { + term: '2', + fields: [ + 'metadata.name', + 'metadata.title', + 'spec.profile.displayName', + ], + }, + }); + }); + }); + it('should send backend filters', async () => { const { result } = renderHook(() => useEntityList(), { wrapper: createWrapper({ pagination }), diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index 1ce87c262e..abb366a568 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -234,7 +234,7 @@ export const EntityListProvider = ( if (!isEqual(previousBackendFilter, backendFilter)) { const response = await catalogApi.queryEntities({ - filter: backendFilter, + ...backendFilter, limit, orderFields: [{ field: 'metadata.name', order: 'asc' }], }); @@ -317,7 +317,7 @@ export const EntityListProvider = ( // changing filters will affect pagination, so we need to reset // the cursor and start from the first page. // TODO(vinzscam): this is currently causing issues at page reload - // where the state is not kept. Unfortunately we need to rething + // where the state is not kept. Unfortunately we need to rethink // the way filters work in order to fix this. setCursor(undefined); setRequestedFilters(prevFilters => { diff --git a/plugins/catalog-react/src/utils/filters.ts b/plugins/catalog-react/src/utils/filters.ts index 580d7b0b76..82d7358b91 100644 --- a/plugins/catalog-react/src/utils/filters.ts +++ b/plugins/catalog-react/src/utils/filters.ts @@ -27,15 +27,30 @@ import { UserListFilter, } from '../filters'; -export function reduceCatalogFilters( - filters: EntityFilter[], -): Record { - return filters.reduce((compoundFilter, filter) => { - return { - ...compoundFilter, - ...(filter.getCatalogFilters ? filter.getCatalogFilters() : {}), - }; - }, {} as Record); +export interface CatalogFilters { + filter: Record; + fullTextFilter?: { + term: string; + }; +} + +function isEntityTextFilter(t: EntityFilter): t is EntityTextFilter { + return !!(t as EntityTextFilter).getFullTextFilters; +} + +export function reduceCatalogFilters(filters: EntityFilter[]): CatalogFilters { + const condensedFilters = filters.reduce( + (compoundFilter, filter) => { + return { + ...compoundFilter, + ...(filter.getCatalogFilters ? filter.getCatalogFilters() : {}), + }; + }, + {}, + ); + + const fullTextFilter = filters.find(isEntityTextFilter)?.getFullTextFilters(); + return { filter: condensedFilters, fullTextFilter }; } /** diff --git a/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.test.tsx index dbf37c2d5c..eb1acc0cee 100644 --- a/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.test.tsx @@ -13,11 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { fireEvent, render } from '@testing-library/react'; import { PaginatedCatalogTable } from './PaginatedCatalogTable'; import { screen } from '@testing-library/react'; import { CatalogTableRow } from './types'; +import { + DefaultEntityFilters, + EntityListContextProps, + MockEntityListContextProvider, +} from '@backstage/plugin-catalog-react'; describe('PaginatedCatalogTable', () => { const data = new Array(100).fill(0).map((_, index) => { @@ -45,8 +50,21 @@ describe('PaginatedCatalogTable', () => { }, ]; + const wrapInContext = ( + node: ReactNode, + value?: Partial>, + ) => { + return ( + + {node} + + ); + }; + it('should display all the items', () => { - render(); + render( + wrapInContext(), + ); for (const item of data) { expect(screen.queryByText(item.resolved.name)).toBeInTheDocument(); @@ -55,7 +73,13 @@ describe('PaginatedCatalogTable', () => { it('should display and invoke the next button', async () => { const { rerender } = render( - , + wrapInContext( + , + ), ); expect( @@ -64,7 +88,11 @@ describe('PaginatedCatalogTable', () => { const fn = jest.fn(); - rerender(); + rerender( + wrapInContext( + , + ), + ); const nextButton = screen.queryAllByRole('button', { name: 'Next Page', @@ -77,7 +105,13 @@ describe('PaginatedCatalogTable', () => { it('should display and invoke the prev button', async () => { const { rerender } = render( - , + wrapInContext( + , + ), ); expect( @@ -86,7 +120,11 @@ describe('PaginatedCatalogTable', () => { const fn = jest.fn(); - rerender(); + rerender( + wrapInContext( + , + ), + ); const prevButton = screen.queryAllByRole('button', { name: 'Previous Page', diff --git a/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx index 771878b65c..0311cd22ca 100644 --- a/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx @@ -18,6 +18,10 @@ import React from 'react'; import { Table, TableProps } from '@backstage/core-components'; import { CatalogTableRow } from './types'; +import { + EntityTextFilter, + useEntityList, +} from '@backstage/plugin-catalog-react'; type PaginatedCatalogTableProps = { prev?(): void; @@ -29,6 +33,7 @@ type PaginatedCatalogTableProps = { */ export function PaginatedCatalogTable(props: PaginatedCatalogTableProps) { const { columns, data, next, prev } = props; + const { updateFilters } = useEntityList(); return ( + updateFilters({ + text: searchText ? new EntityTextFilter(searchText) : undefined, + }) + } onPageChange={page => { if (page > 0) { next?.();