Merge pull request #22131 from sennyeya/server-side-pagination-search

feat(catalog-pagination): Add support for server side text filtering.
This commit is contained in:
Ben Lambert
2024-02-14 14:55:29 +01:00
committed by GitHub
15 changed files with 235 additions and 30 deletions
@@ -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' } },
@@ -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(),
+5
View File
@@ -541,6 +541,11 @@ export class EntityTextFilter implements EntityFilter {
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
getFullTextFilters(): {
term: string;
fields: string[];
};
// (undocumented)
readonly value: string;
}
@@ -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;
}
@@ -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<string, string | symbol | (string | symbol)[]>;
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;
@@ -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
+8
View File
@@ -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> {
@@ -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('<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' },
@@ -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 }),
@@ -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' }],
});
@@ -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 => {
+24 -9
View File
@@ -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 };
}
/**
@@ -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<EntityListContextProps<DefaultEntityFilters>>,
) => {
return (
<MockEntityListContextProvider value={value}>
{node}
</MockEntityListContextProvider>
);
};
it('should display all the items', () => {
render(<PaginatedCatalogTable data={data} columns={columns} />);
render(
wrapInContext(<PaginatedCatalogTable data={data} columns={columns} />),
);
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(
<PaginatedCatalogTable data={data} columns={columns} next={undefined} />,
wrapInContext(
<PaginatedCatalogTable
data={data}
columns={columns}
next={undefined}
/>,
),
);
expect(
@@ -64,7 +88,11 @@ describe('PaginatedCatalogTable', () => {
const fn = jest.fn();
rerender(<PaginatedCatalogTable data={data} columns={columns} next={fn} />);
rerender(
wrapInContext(
<PaginatedCatalogTable data={data} columns={columns} next={fn} />,
),
);
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(
<PaginatedCatalogTable data={data} columns={columns} prev={undefined} />,
wrapInContext(
<PaginatedCatalogTable
data={data}
columns={columns}
prev={undefined}
/>,
),
);
expect(
@@ -86,7 +120,11 @@ describe('PaginatedCatalogTable', () => {
const fn = jest.fn();
rerender(<PaginatedCatalogTable data={data} columns={columns} prev={fn} />);
rerender(
wrapInContext(
<PaginatedCatalogTable data={data} columns={columns} prev={fn} />,
),
);
const prevButton = screen.queryAllByRole('button', {
name: 'Previous Page',
@@ -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 (
<Table
@@ -41,6 +46,11 @@ export function PaginatedCatalogTable(props: PaginatedCatalogTableProps) {
pageSize: Number.MAX_SAFE_INTEGER,
emptyRowsWhenPaging: false,
}}
onSearchChange={(searchText: string) =>
updateFilters({
text: searchText ? new EntityTextFilter(searchText) : undefined,
})
}
onPageChange={page => {
if (page > 0) {
next?.();