From 78475c33a4842c1a09f0be4fa2dc236eb7534054 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Thu, 15 Feb 2024 09:48:18 +0200 Subject: [PATCH] feat: allow offset mode pagination in entity list Signed-off-by: Heikki Hellgren --- .changeset/hip-poems-invent.md | 8 + packages/app/src/App.tsx | 5 +- packages/catalog-client/api-report.md | 1 + packages/catalog-client/src/CatalogClient.ts | 4 + .../src/generated/apis/DefaultApi.client.ts | 3 +- packages/catalog-client/src/types/api.ts | 1 + packages/catalog-client/src/utils.ts | 7 - plugins/catalog-backend/src/catalog/types.ts | 1 + .../src/schema/openapi.generated.ts | 3 + .../catalog-backend/src/schema/openapi.yaml | 1 + .../src/service/DefaultEntitiesCatalog.ts | 6 + .../src/service/createRouter.ts | 1 + plugins/catalog-react/api-report.md | 27 +- plugins/catalog-react/src/hooks/index.ts | 1 + .../src/hooks/useEntityListProvider.test.tsx | 234 +++++++++++++++++- .../src/hooks/useEntityListProvider.tsx | 117 +++++++-- .../catalog-react/src/testUtils/providers.tsx | 5 + plugins/catalog-react/src/types.ts | 6 + plugins/catalog/api-report.md | 7 +- .../CatalogPage/DefaultCatalogPage.tsx | 10 +- .../components/CatalogTable/CatalogTable.tsx | 38 ++- ...x => CursorPaginatedCatalogTable.test.tsx} | 25 +- ...le.tsx => CursorPaginatedCatalogTable.tsx} | 3 +- .../OffsetPaginatedCatalogTable.test.tsx | 109 ++++++++ .../OffsetPaginatedCatalogTable.tsx | 73 ++++++ 25 files changed, 624 insertions(+), 72 deletions(-) create mode 100644 .changeset/hip-poems-invent.md rename plugins/catalog/src/components/CatalogTable/{PaginatedCatalogTable.test.tsx => CursorPaginatedCatalogTable.test.tsx} (87%) rename plugins/catalog/src/components/CatalogTable/{PaginatedCatalogTable.tsx => CursorPaginatedCatalogTable.tsx} (95%) create mode 100644 plugins/catalog/src/components/CatalogTable/OffsetPaginatedCatalogTable.test.tsx create mode 100644 plugins/catalog/src/components/CatalogTable/OffsetPaginatedCatalogTable.tsx diff --git a/.changeset/hip-poems-invent.md b/.changeset/hip-poems-invent.md new file mode 100644 index 0000000000..d4f8171c2b --- /dev/null +++ b/.changeset/hip-poems-invent.md @@ -0,0 +1,8 @@ +--- +'@backstage/catalog-client': patch +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-catalog-react': patch +'@backstage/plugin-catalog': patch +--- + +Allow offset mode paging in entity list provider diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index ba556e8616..2b0bb00929 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -117,7 +117,10 @@ const routes = ( }> {homePage} - } /> + } + /> } diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index ce25990fd0..203028edce 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -269,6 +269,7 @@ export type QueryEntitiesCursorRequest = { export type QueryEntitiesInitialRequest = { fields?: string[]; limit?: number; + offset?: number; filter?: EntityFilterQuery; orderFields?: EntityOrderQuery; fullTextFilter?: { diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 2e7912b69a..4de314c1e8 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -191,6 +191,7 @@ export class CatalogClient implements CatalogApi { fields = [], filter, limit, + offset, orderFields, fullTextFilter, } = request; @@ -199,6 +200,9 @@ export class CatalogClient implements CatalogApi { if (limit !== undefined) { params.limit = limit; } + if (offset !== undefined) { + params.offset = offset; + } if (orderFields !== undefined) { params.orderField = ( Array.isArray(orderFields) ? orderFields : [orderFields] diff --git a/packages/catalog-client/src/generated/apis/DefaultApi.client.ts b/packages/catalog-client/src/generated/apis/DefaultApi.client.ts index 7619e69504..1f1c54d6f8 100644 --- a/packages/catalog-client/src/generated/apis/DefaultApi.client.ts +++ b/packages/catalog-client/src/generated/apis/DefaultApi.client.ts @@ -247,6 +247,7 @@ export class DefaultApiClient { query: { fields?: Array; limit?: number; + offset?: number; orderField?: Array; cursor?: string; filter?: Array; @@ -258,7 +259,7 @@ export class DefaultApiClient { ): Promise> { const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); - const uriTemplate = `/entities/by-query{?fields,limit,orderField*,cursor,filter*,fullTextFilterTerm,fullTextFilterFields}`; + const uriTemplate = `/entities/by-query{?fields,limit,offset,orderField*,cursor,filter*,fullTextFilterTerm,fullTextFilterFields}`; const uri = parser.parse(uriTemplate).expand({ ...request.query, diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 7eded50776..c8fbc7e7ac 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -412,6 +412,7 @@ export type QueryEntitiesRequest = export type QueryEntitiesInitialRequest = { fields?: string[]; limit?: number; + offset?: number; filter?: EntityFilterQuery; orderFields?: EntityOrderQuery; fullTextFilter?: { diff --git a/packages/catalog-client/src/utils.ts b/packages/catalog-client/src/utils.ts index de21cea0d4..9d51c34e71 100644 --- a/packages/catalog-client/src/utils.ts +++ b/packages/catalog-client/src/utils.ts @@ -17,7 +17,6 @@ import { QueryEntitiesCursorRequest, QueryEntitiesInitialRequest, - QueryEntitiesRequest, } from './types/api'; export function isQueryEntitiesInitialRequest( @@ -25,9 +24,3 @@ export function isQueryEntitiesInitialRequest( ): request is QueryEntitiesInitialRequest { return !(request as QueryEntitiesCursorRequest).cursor; } - -export function isQueryEntitiesCursorRequest( - request: QueryEntitiesRequest, -): request is QueryEntitiesCursorRequest { - return !isQueryEntitiesInitialRequest(request); -} diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 9cc83098b3..a02c74c0c1 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -196,6 +196,7 @@ export interface QueryEntitiesInitialRequest { credentials: BackstageCredentials; fields?: (entity: Entity) => Entity; limit?: number; + offset?: number; filter?: EntityFilter; orderFields?: EntityOrder[]; fullTextFilter?: { diff --git a/plugins/catalog-backend/src/schema/openapi.generated.ts b/plugins/catalog-backend/src/schema/openapi.generated.ts index 550db58f2e..f3bd46c05c 100644 --- a/plugins/catalog-backend/src/schema/openapi.generated.ts +++ b/plugins/catalog-backend/src/schema/openapi.generated.ts @@ -1149,6 +1149,9 @@ export const spec = { { $ref: '#/components/parameters/limit', }, + { + $ref: '#/components/parameters/offset', + }, { $ref: '#/components/parameters/orderField', }, diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index 75dc1d9bbc..b02f561bc3 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -885,6 +885,7 @@ paths: parameters: - $ref: '#/components/parameters/fields' - $ref: '#/components/parameters/limit' + - $ref: '#/components/parameters/offset' - $ref: '#/components/parameters/orderField' - $ref: '#/components/parameters/cursor' - $ref: '#/components/parameters/filter' diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index f9f731e3e4..c71f242969 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -495,6 +495,12 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { ]); } + if ( + isQueryEntitiesInitialRequest(request) && + request.offset !== undefined + ) { + dbQuery.offset(request.offset); + } // fetch an extra item to check if there are more items. dbQuery.limit(isFetchingBackwards ? limit : limit + 1); diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 8207db879d..83084efa97 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -154,6 +154,7 @@ export async function createRouter( const { items, pageInfo, totalItems } = await entitiesCatalog.queryEntities({ limit: req.query.limit, + offset: req.query.offset, ...parseQueryEntitiesParams(req.query), credentials: await httpAuth.credentials(req), }); diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 79aad53c33..274f1c0d68 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -307,8 +307,26 @@ export type EntityListContextProps< prev?: () => void; }; totalItems?: number; + limit: number; + offset?: number; + setLimit: (limit: number) => void; + setOffset?: (offset: number) => void; + paginationMode: PaginationMode; }; +// @public (undocumented) +export type EntityListPagination = + | boolean + | { + mode?: 'cursor'; + limit?: number; + } + | { + mode: 'offset'; + limit?: number; + offset?: number; + }; + // @public export const EntityListProvider: ( props: EntityListProviderProps, @@ -316,11 +334,7 @@ export const EntityListProvider: ( // @public (undocumented) export type EntityListProviderProps = PropsWithChildren<{ - pagination?: - | boolean - | { - limit?: number; - }; + pagination?: EntityListPagination; }>; // @public (undocumented) @@ -692,6 +706,9 @@ export class MockStarredEntitiesApi implements StarredEntitiesApi { toggleStarred(entityRef: string): Promise; } +// @public (undocumented) +export type PaginationMode = 'cursor' | 'offset' | 'none'; + // @public export interface StarredEntitiesApi { starredEntitie$(): Observable>; diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index c3021f8992..befcce520d 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -33,6 +33,7 @@ export type { DefaultEntityFilters, EntityListContextProps, EntityListProviderProps, + PaginationMode, } from './useEntityListProvider'; export { useEntityTypeFilter } from './useEntityTypeFilter'; export { useRelatedEntities } from './useRelatedEntities'; diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index 9afa81e2dd..762fba1897 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -31,7 +31,7 @@ import qs from 'qs'; import React, { PropsWithChildren } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { catalogApiRef } from '../api'; -import { starredEntitiesApiRef, MockStarredEntitiesApi } from '../apis'; +import { MockStarredEntitiesApi, starredEntitiesApiRef } from '../apis'; import { EntityKindFilter, EntityTextFilter, @@ -42,6 +42,7 @@ import { EntityListProvider, useEntityList } from './useEntityListProvider'; import { useMountEffect } from '@react-hookz/web'; import { translationApiRef } from '@backstage/core-plugin-api/alpha'; import { MockTranslationApi } from '@backstage/test-utils/alpha'; +import { EntityListPagination } from '../types'; const entities: Entity[] = [ { @@ -91,7 +92,7 @@ const mockCatalogApi: Partial> = { }; const createWrapper = - (options: { location?: string; pagination: boolean }) => + (options: { location?: string; pagination: EntityListPagination }) => (props: PropsWithChildren) => { const InitialFiltersWrapper = ({ children }: PropsWithChildren) => { const { updateFilters } = useEntityList(); @@ -556,3 +557,232 @@ describe('', () => { }); }); }); + +describe('', () => { + const origReplaceState = window.history.replaceState; + const pagination: EntityListPagination = { mode: 'offset' }; + const limit = 20; + const orderFields = [{ field: 'metadata.name', order: 'asc' }]; + + beforeEach(() => { + window.history.replaceState = jest.fn(); + }); + afterEach(() => { + window.history.replaceState = origReplaceState; + }); + + beforeEach(() => { + 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 }), + }); + + await waitFor(() => { + expect(result.current.backendEntities.length).toBe(2); + }); + + expect(result.current.entities.length).toBe(2); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { kind: 'component' }, + limit, + orderFields, + }); + }); + + it('resolves frontend filters', async () => { + const { result } = renderHook(() => useEntityList(), { + wrapper: createWrapper({ pagination }), + initialProps: { + userFilter: 'all', + }, + }); + + act(() => + result.current.updateFilters({ + user: EntityUserFilter.owned(ownershipEntityRefs), + }), + ); + + await waitFor(() => { + expect(result.current.backendEntities.length).toBe(2); + expect(result.current.entities.length).toBe(1); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); + }); + }); + + it('resolves query param filter values', async () => { + const query = qs.stringify({ + filters: { kind: 'component', type: 'service' }, + }); + const { result } = renderHook(() => useEntityList(), { + wrapper: createWrapper({ + location: `/catalog?${query}`, + pagination, + }), + }); + + await waitFor(() => { + expect(result.current.queryParameters).toBeTruthy(); + }); + expect(result.current.queryParameters).toEqual({ + kind: 'component', + type: 'service', + }); + }); + + it('fetch when frontend filters change', async () => { + const { result } = renderHook(() => useEntityList(), { + wrapper: createWrapper({ pagination }), + }); + + await waitFor(() => { + expect(result.current.entities.length).toBe(2); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); + }); + + act(() => + result.current.updateFilters({ + user: EntityUserFilter.owned(ownershipEntityRefs), + }), + ); + + await waitFor(() => { + expect(result.current.entities.length).toBe(1); + }); + + await waitFor(() => { + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(2); + }); + }); + + it('fetch when limit change', async () => { + const { result } = renderHook(() => useEntityList(), { + wrapper: createWrapper({ pagination }), + }); + + await waitFor(() => { + expect(result.current.entities.length).toBe(2); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); + }); + + act(() => result.current.setLimit(50)); + + await waitFor(() => { + expect(result.current.entities.length).toBe(2); + }); + + await waitFor(() => { + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(2); + expect(result.current.limit).toEqual(50); + }); + }); + + it('debounces multiple filter changes', async () => { + const { result } = renderHook(() => useEntityList(), { + wrapper: createWrapper({ pagination }), + }); + + await waitFor(() => { + expect(result.current.backendEntities.length).toBeGreaterThan(0); + }); + expect(result.current.backendEntities.length).toBe(2); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); + + await act(async () => { + result.current.updateFilters({ kind: new EntityKindFilter('api') }); + result.current.updateFilters({ type: new EntityTypeFilter('service') }); + }); + + await waitFor(() => { + expect(mockCatalogApi.queryEntities).toHaveBeenNthCalledWith(2, { + filter: { kind: 'api', 'spec.type': ['service'] }, + limit, + orderFields, + }); + }); + }); + + it('debounces multiple offset changes', async () => { + const { result } = renderHook(() => useEntityList(), { + wrapper: createWrapper({ pagination }), + }); + + await waitFor(() => { + expect(result.current.backendEntities.length).toBeGreaterThan(0); + }); + expect(result.current.backendEntities.length).toBe(2); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); + + await act(async () => { + result.current.setOffset!(5); + result.current.setOffset!(10); + }); + + await waitFor(() => { + expect(mockCatalogApi.queryEntities).toHaveBeenNthCalledWith(2, { + filter: { kind: 'component' }, + limit, + offset: 10, + orderFields, + }); + expect(result.current.offset).toEqual(10); + }); + }); + + it('returns an error on catalogApi failure', async () => { + const { result } = renderHook(() => useEntityList(), { + wrapper: createWrapper({ pagination }), + }); + + await waitFor(() => { + expect(result.current.backendEntities.length).toBeGreaterThan(0); + }); + expect(result.current.backendEntities.length).toBe(2); + + mockCatalogApi.queryEntities!.mockRejectedValueOnce('error'); + act(() => { + result.current.updateFilters({ kind: new EntityKindFilter('api') }); + }); + await waitFor(() => { + expect(result.current.error).toBeDefined(); + }); + }); +}); diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index da2a37381c..274ec52a18 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -34,16 +34,16 @@ import { EntityErrorFilter, EntityKindFilter, EntityLifecycleFilter, + EntityNamespaceFilter, EntityOrphanFilter, EntityOwnerFilter, EntityTagFilter, EntityTextFilter, EntityTypeFilter, - UserListFilter, - EntityNamespaceFilter, EntityUserFilter, + UserListFilter, } from '../filters'; -import { EntityFilter } from '../types'; +import { EntityFilter, EntityListPagination } from '../types'; import { reduceBackendCatalogFilters, reduceCatalogFilters, @@ -66,6 +66,9 @@ export type DefaultEntityFilters = { namespace?: EntityNamespaceFilter; }; +/** @public */ +export type PaginationMode = 'cursor' | 'offset' | 'none'; + /** @public */ export type EntityListContextProps< EntityFilters extends DefaultEntityFilters = DefaultEntityFilters, @@ -108,8 +111,12 @@ export type EntityListContextProps< next?: () => void; prev?: () => void; }; - totalItems?: number; + limit: number; + offset?: number; + setLimit: (limit: number) => void; + setOffset?: (offset: number) => void; + paginationMode: PaginationMode; }; /** @@ -127,13 +134,15 @@ type OutputState = { backendEntities: Entity[]; pageInfo?: QueryEntitiesResponse['pageInfo']; totalItems?: number; + offset?: number; + limit?: number; }; /** * @public */ export type EntityListProviderProps = PropsWithChildren<{ - pagination?: boolean | { limit?: number }; + pagination?: EntityListPagination; }>; /** @@ -155,31 +164,62 @@ export const EntityListProvider = ( // update of the URL or two catalog sidebar links with different catalog filters. const location = useLocation(); - const enablePagination = - props.pagination === true || typeof props.pagination === 'object'; + const getPaginationMode = (): PaginationMode => { + if (props.pagination === true) { + return 'cursor'; + } + return typeof props.pagination === 'object' + ? props.pagination.mode ?? 'cursor' + : 'none'; + }; - const limit = - props.pagination && - typeof props.pagination === 'object' && - typeof props.pagination.limit === 'number' - ? props.pagination.limit - : 20; + const paginationMode: PaginationMode = getPaginationMode(); + const paginationLimit = + typeof props.pagination === 'object' ? props.pagination.limit ?? 20 : 20; - const { queryParameters, cursor: initialCursor } = useMemo(() => { + const { + queryParameters, + cursor: initialCursor, + offset: initialOffset, + limit: initialLimit, + } = useMemo(() => { const parsed = qs.parse(location.search, { ignoreQueryPrefix: true, }); + let limit = paginationLimit; + if (typeof parsed.limit === 'string') { + const queryLimit = Number.parseInt(parsed.limit, 10); + if (!isNaN(queryLimit)) { + limit = queryLimit; + } + } + + const offset = + typeof parsed.offset === 'string' && paginationMode === 'offset' + ? Number.parseInt(parsed.offset, 10) + : undefined; + return { queryParameters: (parsed.filters ?? {}) as Record< string, string | string[] >, - cursor: typeof parsed.cursor === 'string' ? parsed.cursor : undefined, + cursor: + typeof parsed.cursor === 'string' && paginationMode === 'cursor' + ? parsed.cursor + : undefined, + offset: + paginationMode === 'offset' && offset && !isNaN(offset) + ? offset + : undefined, + limit, }; - }, [location]); + }, [paginationMode, location.search, paginationLimit]); const [cursor, setCursor] = useState(initialCursor); + const [offset, setOffset] = useState(initialOffset); + const [limit, setLimit] = useState(initialLimit); const [outputState, setOutputState] = useState>( () => { @@ -187,7 +227,9 @@ export const EntityListProvider = ( appliedFilters: {} as EntityFilters, entities: [], backendEntities: [], - pageInfo: enablePagination ? {} : undefined, + pageInfo: paginationMode === 'cursor' ? {} : undefined, + offset, + limit, }; }, ); @@ -212,7 +254,7 @@ export const EntityListProvider = ( {} as Record, ); - if (enablePagination) { + if (paginationMode !== 'none') { if (cursor) { if (cursor !== outputState.appliedCursor) { const entityFilter = reduceEntityFilters(compacted); @@ -236,10 +278,14 @@ export const EntityListProvider = ( compact(Object.values(outputState.appliedFilters)), ); - if (!isEqual(previousBackendFilter, backendFilter)) { + if ( + paginationMode === 'offset' || + !isEqual(previousBackendFilter, backendFilter) + ) { const response = await catalogApi.queryEntities({ ...backendFilter, limit, + offset, orderFields: [{ field: 'metadata.name', order: 'asc' }], }); setOutputState({ @@ -248,6 +294,8 @@ export const EntityListProvider = ( entities: response.items.filter(entityFilter), pageInfo: response.pageInfo, totalItems: response.totalItems, + limit, + offset, }); } } @@ -290,7 +338,7 @@ export const EntityListProvider = ( ignoreQueryPrefix: true, }); const newParams = qs.stringify( - { ...oldParams, filters: queryParams, cursor }, + { ...oldParams, filters: queryParams, cursor, offset, limit }, { addQueryPrefix: true, arrayFormat: 'repeat' }, ); const newUrl = `${window.location.pathname}${newParams}`; @@ -308,14 +356,16 @@ export const EntityListProvider = ( requestedFilters, outputState, cursor, - enablePagination, + paginationMode, + limit, + offset, ], { loading: true }, ); // Slight debounce on the refresh, since (especially on page load) several // filters will be calling this in rapid succession. - useDebounce(refresh, 10, [requestedFilters, cursor]); + useDebounce(refresh, 10, [requestedFilters, cursor, limit, offset]); const updateFilters = useCallback( ( @@ -339,7 +389,7 @@ export const EntityListProvider = ( ); const pageInfo = useMemo(() => { - if (!enablePagination) { + if (paginationMode !== 'cursor') { return undefined; } @@ -349,7 +399,7 @@ export const EntityListProvider = ( prev: prevCursor ? () => setCursor(prevCursor) : undefined, next: nextCursor ? () => setCursor(nextCursor) : undefined, }; - }, [enablePagination, outputState.pageInfo]); + }, [paginationMode, outputState.pageInfo]); const value = useMemo( () => ({ @@ -362,8 +412,25 @@ export const EntityListProvider = ( error, pageInfo, totalItems: outputState.totalItems, + limit, + offset, + setLimit, + setOffset, + paginationMode, }), - [outputState, updateFilters, queryParameters, loading, error, pageInfo], + [ + outputState, + updateFilters, + queryParameters, + loading, + error, + pageInfo, + limit, + offset, + paginationMode, + setLimit, + setOffset, + ], ); return ( diff --git a/plugins/catalog-react/src/testUtils/providers.tsx b/plugins/catalog-react/src/testUtils/providers.tsx index 6b88168207..9bb46dc6e5 100644 --- a/plugins/catalog-react/src/testUtils/providers.tsx +++ b/plugins/catalog-react/src/testUtils/providers.tsx @@ -73,6 +73,11 @@ export function MockEntityListContextProvider< error: value?.error, totalItems: value?.totalItems ?? (value?.entities ?? defaultValues.entities).length, + limit: value?.limit ?? 20, + offset: value?.offset, + setLimit: value?.setLimit ?? (() => {}), + setOffset: value?.setOffset, + paginationMode: value?.paginationMode ?? 'none', }), [value, defaultValues, filters, updateFilters], ); diff --git a/plugins/catalog-react/src/types.ts b/plugins/catalog-react/src/types.ts index 41c7d75f0d..57b21b634e 100644 --- a/plugins/catalog-react/src/types.ts +++ b/plugins/catalog-react/src/types.ts @@ -46,3 +46,9 @@ export type EntityFilter = { /** @public */ export type UserListFilterKind = 'owned' | 'starred' | 'all'; + +/** @public */ +export type EntityListPagination = + | boolean + | { mode?: 'cursor'; limit?: number } + | { mode: 'offset'; limit?: number; offset?: number }; diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 541cb08887..2c1d2597fe 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -12,6 +12,7 @@ import { ComponentEntity } from '@backstage/catalog-model'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; import { EntityListContextProps } from '@backstage/plugin-catalog-react'; +import { EntityListPagination } from '@backstage/plugin-catalog-react'; import { EntityOwnerPickerProps } from '@backstage/plugin-catalog-react'; import { EntityPresentationApi } from '@backstage/plugin-catalog-react'; import { EntityRefPresentation } from '@backstage/plugin-catalog-react'; @@ -244,11 +245,7 @@ export interface DefaultCatalogPageProps { // (undocumented) ownerPickerMode?: EntityOwnerPickerProps['mode']; // (undocumented) - pagination?: - | boolean - | { - limit?: number; - }; + pagination?: EntityListPagination; // (undocumented) tableOptions?: TableProps['options']; } diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx index a46a552b8d..366427d33d 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx @@ -26,26 +26,26 @@ import { import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; import { CatalogFilterLayout, + DefaultFilters, + EntityListPagination, EntityListProvider, - UserListFilterKind, EntityOwnerPickerProps, + UserListFilterKind, } from '@backstage/plugin-catalog-react'; import React, { ReactNode } from 'react'; import { createComponentRouteRef } from '../../routes'; import { CatalogTable, CatalogTableRow } from '../CatalogTable'; import { catalogTranslationRef } from '../../alpha/translation'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; - import { CatalogTableColumnsFunc } from '../CatalogTable/types'; import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha'; import { usePermission } from '@backstage/plugin-permission-react'; -import { DefaultFilters } from '@backstage/plugin-catalog-react'; /** @internal */ export type BaseCatalogPageProps = { filters: ReactNode; content?: ReactNode; - pagination?: boolean | { limit?: number }; + pagination?: EntityListPagination; }; /** @internal */ @@ -95,9 +95,9 @@ export interface DefaultCatalogPageProps { tableOptions?: TableProps['options']; emptyContent?: ReactNode; ownerPickerMode?: EntityOwnerPickerProps['mode']; - pagination?: boolean | { limit?: number }; filters?: ReactNode; initiallySelectedNamespaces?: string[]; + pagination?: EntityListPagination; } export function DefaultCatalogPage(props: DefaultCatalogPageProps) { diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index ac459b83d6..2d5e78a0d6 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -43,7 +43,8 @@ import pluralize from 'pluralize'; import React, { ReactNode, useMemo } from 'react'; import { columnFactories } from './columns'; import { CatalogTableColumnsFunc, CatalogTableRow } from './types'; -import { PaginatedCatalogTable } from './PaginatedCatalogTable'; +import { OffsetPaginatedCatalogTable } from './OffsetPaginatedCatalogTable'; +import { CursorPaginatedCatalogTable } from './CursorPaginatedCatalogTable'; import { defaultCatalogTableColumnsFunc } from './defaultCatalogTableColumnsFunc'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { catalogTranslationRef } from '../../alpha/translation'; @@ -82,9 +83,17 @@ export const CatalogTable = (props: CatalogTableProps) => { } = props; const { isStarredEntity, toggleStarredEntity } = useStarredEntities(); const entityListContext = useEntityList(); - const { loading, error, entities, filters, pageInfo, totalItems } = - entityListContext; - const enablePagination = !!pageInfo; + + const { + loading, + error, + entities, + filters, + pageInfo, + totalItems, + paginationMode, + } = entityListContext; + const tableColumns = useMemo( () => typeof columns === 'function' ? columns(entityListContext) : columns, @@ -182,9 +191,24 @@ export const CatalogTable = (props: CatalogTableProps) => { ...tableOptions, }; - if (enablePagination) { + if (paginationMode === 'cursor') { return ( - + ); + } else if (paginationMode === 'offset') { + return ( + { subtitle={subtitle} options={options} data={entities.map(toEntityRow)} - next={pageInfo.next} - prev={pageInfo.prev} /> ); } diff --git a/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CursorPaginatedCatalogTable.test.tsx similarity index 87% rename from plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.test.tsx rename to plugins/catalog/src/components/CatalogTable/CursorPaginatedCatalogTable.test.tsx index c96fe0e067..be250fddbf 100644 --- a/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CursorPaginatedCatalogTable.test.tsx @@ -14,19 +14,18 @@ * limitations under the License. */ import React, { ReactNode } from 'react'; -import { fireEvent, waitFor } from '@testing-library/react'; -import { PaginatedCatalogTable } from './PaginatedCatalogTable'; -import { screen } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { CursorPaginatedCatalogTable } from './CursorPaginatedCatalogTable'; import { CatalogTableRow } from './types'; import { renderInTestApp } from '@backstage/test-utils'; import { - EntityKindFilter, - MockEntityListContextProvider, DefaultEntityFilters, + EntityKindFilter, EntityListContextProps, + MockEntityListContextProvider, } from '@backstage/plugin-catalog-react'; -describe('PaginatedCatalogTable', () => { +describe('CursorPaginatedCatalogTable', () => { const data = new Array(100).fill(0).map((_, index) => { const name = `component-${index}`; return { @@ -65,7 +64,9 @@ describe('PaginatedCatalogTable', () => { it('should display all the items', async () => { await renderInTestApp( - wrapInContext(), + wrapInContext( + , + ), ); for (const item of data) { @@ -76,7 +77,7 @@ describe('PaginatedCatalogTable', () => { it('should display and invoke the next button', async () => { const { rerender } = await renderInTestApp( wrapInContext( - { rerender( wrapInContext( - , + , ), ); @@ -108,7 +109,7 @@ describe('PaginatedCatalogTable', () => { it('should display and invoke the prev button', async () => { const { rerender } = await renderInTestApp( wrapInContext( - { rerender( wrapInContext( - , + , ), ); @@ -148,7 +149,7 @@ describe('PaginatedCatalogTable', () => { }, }} > - { + const data = new Array(100).fill(0).map((_, index) => { + const name = `component-${index}`; + return { + entity: { + apiVersion: '1', + kind: 'component', + metadata: { + name, + }, + }, + resolved: { + name, + entityRef: 'component:default/component', + }, + } as CatalogTableRow; + }); + + const columns = [ + { + title: 'Title', + field: 'entity.metadata.name', + searchable: true, + }, + ]; + + const wrapInContext = ( + node: ReactNode, + value?: Partial>, + ) => { + return ( + + {node} + + ); + }; + + it('should display all the items', async () => { + await renderInTestApp( + wrapInContext( + , + { + setOffset: jest.fn(), + limit: Number.MAX_SAFE_INTEGER, + offset: 0, + totalItems: data.length, + }, + ), + ); + + for (const item of data) { + expect(screen.queryByText(item.resolved.name)).toBeInTheDocument(); + } + }); + + it('should display and invoke the next and previous buttons', async () => { + const offsetFn = jest.fn(); + + await renderInTestApp( + wrapInContext( + , + { setOffset: offsetFn, limit: 10, totalItems: data.length, offset: 0 }, + ), + ); + + expect(offsetFn).toHaveBeenNthCalledWith(1, 0); + const nextButton = screen.queryAllByRole('button', { + name: 'Next Page', + })[0]; + expect(nextButton).toBeEnabled(); + + fireEvent.click(nextButton); + expect(offsetFn).toHaveBeenNthCalledWith(2, 10); + + const prevButton = screen.queryAllByRole('button', { + name: 'Previous Page', + })[0]; + expect(prevButton).toBeEnabled(); + + fireEvent.click(prevButton); + expect(offsetFn).toHaveBeenNthCalledWith(3, 0); + }); +}); diff --git a/plugins/catalog/src/components/CatalogTable/OffsetPaginatedCatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/OffsetPaginatedCatalogTable.tsx new file mode 100644 index 0000000000..1528fb59f9 --- /dev/null +++ b/plugins/catalog/src/components/CatalogTable/OffsetPaginatedCatalogTable.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; + +import { Table, TableProps } from '@backstage/core-components'; +import { CatalogTableRow } from './types'; +import { + EntityTextFilter, + useEntityList, +} from '@backstage/plugin-catalog-react'; + +/** + * @internal + */ +export function OffsetPaginatedCatalogTable( + props: TableProps, +) { + const { columns, data } = props; + const { updateFilters, setLimit, setOffset, limit, totalItems, offset } = + useEntityList(); + const [page, setPage] = React.useState( + offset && limit ? Math.floor(offset / limit) : 0, + ); + + useEffect(() => { + if (totalItems && page * limit >= totalItems) { + setOffset!(Math.max(0, totalItems - limit)); + } else { + setOffset!(Math.max(0, page * limit)); + } + }, [setOffset, page, limit, totalItems]); + + return ( + + updateFilters({ + text: searchText ? new EntityTextFilter(searchText) : undefined, + }) + } + page={page} + onPageChange={newPage => { + setPage(newPage); + }} + onRowsPerPageChange={pageSize => { + setLimit(pageSize); + }} + totalCount={totalItems} + localization={{ pagination: { labelDisplayedRows: '' } }} + /> + ); +}