From 4e759dd9b117a8fc31c6be59694ed6bb36f04cfc Mon Sep 17 00:00:00 2001 From: Aramis Date: Mon, 8 Jan 2024 00:35:51 -0500 Subject: [PATCH] feat(catalog): Add support for server side text filtering. Signed-off-by: Aramis --- packages/app/src/App.tsx | 5 ++- .../src/service/createRouter.test.ts | 43 ++++++++++++++++++- plugins/catalog-backend/src/service/util.ts | 6 +++ .../src/hooks/useEntityListProvider.tsx | 14 +++++- .../CatalogTable/PaginatedCatalogTable.tsx | 10 +++++ 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index d2dde95f68..2ea0fefdea 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -177,7 +177,10 @@ const routes = ( }> {homePage} - } /> + } + /> } diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 05ebf6e1a9..809d970612 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'; @@ -265,6 +265,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/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index 1ce87c262e..81bf8fb9e3 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -124,6 +124,7 @@ type OutputState = { entities: Entity[]; backendEntities: Entity[]; pageInfo?: QueryEntitiesResponse['pageInfo']; + textSearch?: string; }; /** @@ -232,9 +233,17 @@ export const EntityListProvider = ( compact(Object.values(outputState.appliedFilters)), ); - if (!isEqual(previousBackendFilter, backendFilter)) { + if ( + !isEqual(previousBackendFilter, backendFilter) || + requestedFilters.text?.value !== outputState.textSearch + ) { const response = await catalogApi.queryEntities({ filter: backendFilter, + fullTextFilter: requestedFilters.text + ? { + term: requestedFilters.text.value, + } + : undefined, limit, orderFields: [{ field: 'metadata.name', order: 'asc' }], }); @@ -243,6 +252,7 @@ export const EntityListProvider = ( backendEntities: response.items, entities: response.items.filter(entityFilter), pageInfo: response.pageInfo, + textSearch: requestedFilters.text?.value, }); } } @@ -317,7 +327,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/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?.();