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:
@@ -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(),
|
||||
|
||||
Generated
+5
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
Reference in New Issue
Block a user