feat: allow offset mode pagination in entity list
Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
@@ -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
|
||||
@@ -117,7 +117,10 @@ const routes = (
|
||||
<Route path="/home" element={<HomepageCompositionRoot />}>
|
||||
{homePage}
|
||||
</Route>
|
||||
<Route path="/catalog" element={<CatalogIndexPage />} />
|
||||
<Route
|
||||
path="/catalog"
|
||||
element={<CatalogIndexPage pagination={{ mode: 'offset', limit: 20 }} />}
|
||||
/>
|
||||
<Route
|
||||
path="/catalog/:namespace/:kind/:name"
|
||||
element={<CatalogEntityPage />}
|
||||
|
||||
@@ -269,6 +269,7 @@ export type QueryEntitiesCursorRequest = {
|
||||
export type QueryEntitiesInitialRequest = {
|
||||
fields?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
filter?: EntityFilterQuery;
|
||||
orderFields?: EntityOrderQuery;
|
||||
fullTextFilter?: {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -247,6 +247,7 @@ export class DefaultApiClient {
|
||||
query: {
|
||||
fields?: Array<string>;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderField?: Array<string>;
|
||||
cursor?: string;
|
||||
filter?: Array<string>;
|
||||
@@ -258,7 +259,7 @@ export class DefaultApiClient {
|
||||
): Promise<TypedResponse<EntitiesQueryResponse>> {
|
||||
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,
|
||||
|
||||
@@ -412,6 +412,7 @@ export type QueryEntitiesRequest =
|
||||
export type QueryEntitiesInitialRequest = {
|
||||
fields?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
filter?: EntityFilterQuery;
|
||||
orderFields?: EntityOrderQuery;
|
||||
fullTextFilter?: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -196,6 +196,7 @@ export interface QueryEntitiesInitialRequest {
|
||||
credentials: BackstageCredentials;
|
||||
fields?: (entity: Entity) => Entity;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
filter?: EntityFilter;
|
||||
orderFields?: EntityOrder[];
|
||||
fullTextFilter?: {
|
||||
|
||||
@@ -1149,6 +1149,9 @@ export const spec = {
|
||||
{
|
||||
$ref: '#/components/parameters/limit',
|
||||
},
|
||||
{
|
||||
$ref: '#/components/parameters/offset',
|
||||
},
|
||||
{
|
||||
$ref: '#/components/parameters/orderField',
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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: <EntityFilters extends DefaultEntityFilters>(
|
||||
props: EntityListProviderProps,
|
||||
@@ -316,11 +334,7 @@ export const EntityListProvider: <EntityFilters extends DefaultEntityFilters>(
|
||||
|
||||
// @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<void>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type PaginationMode = 'cursor' | 'offset' | 'none';
|
||||
|
||||
// @public
|
||||
export interface StarredEntitiesApi {
|
||||
starredEntitie$(): Observable<Set<string>>;
|
||||
|
||||
@@ -33,6 +33,7 @@ export type {
|
||||
DefaultEntityFilters,
|
||||
EntityListContextProps,
|
||||
EntityListProviderProps,
|
||||
PaginationMode,
|
||||
} from './useEntityListProvider';
|
||||
export { useEntityTypeFilter } from './useEntityTypeFilter';
|
||||
export { useRelatedEntities } from './useRelatedEntities';
|
||||
|
||||
@@ -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<jest.Mocked<CatalogApi>> = {
|
||||
};
|
||||
|
||||
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('<EntityListProvider pagination />', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('<EntityListProvider pagination={{mode: offset}} />', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<EntityFilters extends DefaultEntityFilters> = {
|
||||
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 = <EntityFilters extends DefaultEntityFilters>(
|
||||
// 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<number | undefined>(initialOffset);
|
||||
const [limit, setLimit] = useState(initialLimit);
|
||||
|
||||
const [outputState, setOutputState] = useState<OutputState<EntityFilters>>(
|
||||
() => {
|
||||
@@ -187,7 +227,9 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
|
||||
appliedFilters: {} as EntityFilters,
|
||||
entities: [],
|
||||
backendEntities: [],
|
||||
pageInfo: enablePagination ? {} : undefined,
|
||||
pageInfo: paginationMode === 'cursor' ? {} : undefined,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -212,7 +254,7 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
|
||||
{} as Record<string, string | string[]>,
|
||||
);
|
||||
|
||||
if (enablePagination) {
|
||||
if (paginationMode !== 'none') {
|
||||
if (cursor) {
|
||||
if (cursor !== outputState.appliedCursor) {
|
||||
const entityFilter = reduceEntityFilters(compacted);
|
||||
@@ -236,10 +278,14 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
|
||||
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 = <EntityFilters extends DefaultEntityFilters>(
|
||||
entities: response.items.filter(entityFilter),
|
||||
pageInfo: response.pageInfo,
|
||||
totalItems: response.totalItems,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -290,7 +338,7 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
|
||||
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 = <EntityFilters extends DefaultEntityFilters>(
|
||||
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 = <EntityFilters extends DefaultEntityFilters>(
|
||||
);
|
||||
|
||||
const pageInfo = useMemo(() => {
|
||||
if (!enablePagination) {
|
||||
if (paginationMode !== 'cursor') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -349,7 +399,7 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
|
||||
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 = <EntityFilters extends DefaultEntityFilters>(
|
||||
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 (
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<CatalogTableRow>['options'];
|
||||
}
|
||||
|
||||
@@ -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<CatalogTableRow>['options'];
|
||||
emptyContent?: ReactNode;
|
||||
ownerPickerMode?: EntityOwnerPickerProps['mode'];
|
||||
pagination?: boolean | { limit?: number };
|
||||
filters?: ReactNode;
|
||||
initiallySelectedNamespaces?: string[];
|
||||
pagination?: EntityListPagination;
|
||||
}
|
||||
|
||||
export function DefaultCatalogPage(props: DefaultCatalogPageProps) {
|
||||
|
||||
@@ -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 (
|
||||
<PaginatedCatalogTable
|
||||
<CursorPaginatedCatalogTable
|
||||
columns={tableColumns}
|
||||
emptyContent={emptyContent}
|
||||
isLoading={loading}
|
||||
title={title}
|
||||
actions={actions}
|
||||
subtitle={subtitle}
|
||||
options={options}
|
||||
data={entities.map(toEntityRow)}
|
||||
next={pageInfo?.next}
|
||||
prev={pageInfo?.prev}
|
||||
/>
|
||||
);
|
||||
} else if (paginationMode === 'offset') {
|
||||
return (
|
||||
<OffsetPaginatedCatalogTable
|
||||
columns={tableColumns}
|
||||
emptyContent={emptyContent}
|
||||
isLoading={loading}
|
||||
@@ -193,8 +217,6 @@ export const CatalogTable = (props: CatalogTableProps) => {
|
||||
subtitle={subtitle}
|
||||
options={options}
|
||||
data={entities.map(toEntityRow)}
|
||||
next={pageInfo.next}
|
||||
prev={pageInfo.prev}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+13
-12
@@ -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(<PaginatedCatalogTable data={data} columns={columns} />),
|
||||
wrapInContext(
|
||||
<CursorPaginatedCatalogTable data={data} columns={columns} />,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<PaginatedCatalogTable
|
||||
<CursorPaginatedCatalogTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
next={undefined}
|
||||
@@ -92,7 +93,7 @@ describe('PaginatedCatalogTable', () => {
|
||||
|
||||
rerender(
|
||||
wrapInContext(
|
||||
<PaginatedCatalogTable data={data} columns={columns} next={fn} />,
|
||||
<CursorPaginatedCatalogTable data={data} columns={columns} next={fn} />,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -108,7 +109,7 @@ describe('PaginatedCatalogTable', () => {
|
||||
it('should display and invoke the prev button', async () => {
|
||||
const { rerender } = await renderInTestApp(
|
||||
wrapInContext(
|
||||
<PaginatedCatalogTable
|
||||
<CursorPaginatedCatalogTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
prev={undefined}
|
||||
@@ -124,7 +125,7 @@ describe('PaginatedCatalogTable', () => {
|
||||
|
||||
rerender(
|
||||
wrapInContext(
|
||||
<PaginatedCatalogTable data={data} columns={columns} prev={fn} />,
|
||||
<CursorPaginatedCatalogTable data={data} columns={columns} prev={fn} />,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -148,7 +149,7 @@ describe('PaginatedCatalogTable', () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PaginatedCatalogTable
|
||||
<CursorPaginatedCatalogTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
next={undefined}
|
||||
+2
-1
@@ -28,7 +28,8 @@ type PaginatedCatalogTableProps = {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function PaginatedCatalogTable(props: PaginatedCatalogTableProps) {
|
||||
|
||||
export function CursorPaginatedCatalogTable(props: PaginatedCatalogTableProps) {
|
||||
const { columns, data, next, prev, title, isLoading, options, ...restProps } =
|
||||
props;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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, { ReactNode } from 'react';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { CatalogTableRow } from './types';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import {
|
||||
DefaultEntityFilters,
|
||||
EntityListContextProps,
|
||||
MockEntityListContextProvider,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { OffsetPaginatedCatalogTable } from './OffsetPaginatedCatalogTable';
|
||||
|
||||
describe('OffsetPaginatedCatalogTable', () => {
|
||||
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<EntityListContextProps<DefaultEntityFilters>>,
|
||||
) => {
|
||||
return (
|
||||
<MockEntityListContextProvider value={value}>
|
||||
{node}
|
||||
</MockEntityListContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it('should display all the items', async () => {
|
||||
await renderInTestApp(
|
||||
wrapInContext(
|
||||
<OffsetPaginatedCatalogTable data={data} columns={columns} />,
|
||||
{
|
||||
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(
|
||||
<OffsetPaginatedCatalogTable data={data} columns={columns} />,
|
||||
{ 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);
|
||||
});
|
||||
});
|
||||
@@ -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<CatalogTableRow>,
|
||||
) {
|
||||
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 (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={data}
|
||||
options={{
|
||||
paginationPosition: 'both',
|
||||
pageSizeOptions: [5, 10, 20, 50, 100],
|
||||
pageSize: limit,
|
||||
emptyRowsWhenPaging: false,
|
||||
}}
|
||||
onSearchChange={(searchText: string) =>
|
||||
updateFilters({
|
||||
text: searchText ? new EntityTextFilter(searchText) : undefined,
|
||||
})
|
||||
}
|
||||
page={page}
|
||||
onPageChange={newPage => {
|
||||
setPage(newPage);
|
||||
}}
|
||||
onRowsPerPageChange={pageSize => {
|
||||
setLimit(pageSize);
|
||||
}}
|
||||
totalCount={totalItems}
|
||||
localization={{ pagination: { labelDisplayedRows: '' } }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user