Skip to content

Commit

Permalink
Merge pull request backstage#22131 from sennyeya/server-side-paginati…
Browse files Browse the repository at this point in the history
…on-search

feat(catalog-pagination): Add support for server side text filtering.
  • Loading branch information
benjdlambert authored Feb 14, 2024
2 parents 61ae2ed + f6a030a commit 4c80f78
Show file tree
Hide file tree
Showing 15 changed files with 235 additions and 30 deletions.
5 changes: 5 additions & 0 deletions .changeset/clever-chicken-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': minor
---

Added support for server side text filtering to paginated entity requests.
5 changes: 5 additions & 0 deletions .changeset/long-stingrays-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': minor
---

Updated the paginated catalog table to support server side text filtering.
5 changes: 5 additions & 0 deletions .changeset/purple-camels-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---

Fixed a bug where `fullTextFilter` wasn't preserved correctly in the cursor.
43 changes: 42 additions & 1 deletion plugins/catalog-backend/src/service/createRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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' } },
Expand Down
6 changes: 6 additions & 0 deletions plugins/catalog-backend/src/service/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ export const cursorParser: z.ZodSchema<Cursor> = 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(),
Expand Down
5 changes: 5 additions & 0 deletions plugins/catalog-react/api-report.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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)),
);

Expand All @@ -47,7 +47,7 @@ export function useOwnedEntitiesCount() {
async (req: {
ownershipEntityRefs: string[];
owners: EntityOwnerFilter | undefined;
filter: Record<string, string | symbol | (string | symbol)[]>;
filter: CatalogFilters;
}) => {
const ownedClaims = getOwnedCountClaims(
req.owners,
Expand All @@ -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,
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions plugins/catalog-react/src/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | string[] | undefined>,
): Array<string> {
Expand Down
59 changes: 59 additions & 0 deletions plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { catalogApiRef } from '../api';
import { starredEntitiesApiRef, MockStarredEntitiesApi } from '../apis';
import {
EntityKindFilter,
EntityTextFilter,
EntityTypeFilter,
EntityUserFilter,
} from '../filters';
Expand Down Expand Up @@ -171,6 +172,30 @@ describe('<EntityListProvider />', () => {
});
});

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' },
Expand Down Expand Up @@ -289,6 +314,40 @@ describe('<EntityListProvider pagination />', () => {
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 }),
Expand Down
4 changes: 2 additions & 2 deletions plugins/catalog-react/src/hooks/useEntityListProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(

if (!isEqual(previousBackendFilter, backendFilter)) {
const response = await catalogApi.queryEntities({
filter: backendFilter,
...backendFilter,
limit,
orderFields: [{ field: 'metadata.name', order: 'asc' }],
});
Expand Down Expand Up @@ -317,7 +317,7 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
// 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 => {
Expand Down
33 changes: 24 additions & 9 deletions plugins/catalog-react/src/utils/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,30 @@ import {
UserListFilter,
} from '../filters';

export function reduceCatalogFilters(
filters: EntityFilter[],
): Record<string, string | symbol | (string | symbol)[]> {
return filters.reduce((compoundFilter, filter) => {
return {
...compoundFilter,
...(filter.getCatalogFilters ? filter.getCatalogFilters() : {}),
};
}, {} as Record<string, string | symbol | (string | symbol)[]>);
export interface CatalogFilters {
filter: Record<string, string | symbol | (string | symbol)[]>;
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<CatalogFilters['filter']>(
(compoundFilter, filter) => {
return {
...compoundFilter,
...(filter.getCatalogFilters ? filter.getCatalogFilters() : {}),
};
},
{},
);

const fullTextFilter = filters.find(isEntityTextFilter)?.getFullTextFilters();
return { filter: condensedFilters, fullTextFilter };
}

/**
Expand Down
Loading

0 comments on commit 4c80f78

Please sign in to comment.