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: '' } }}
+ />
+ );
+}