feat: allow offset mode pagination in entity list

Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
Heikki Hellgren
2024-02-15 09:48:18 +02:00
parent 6dca65f95b
commit 78475c33a4
25 changed files with 624 additions and 72 deletions
+8
View File
@@ -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
+4 -1
View File
@@ -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 />}
+1
View File
@@ -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,
+1
View File
@@ -412,6 +412,7 @@ export type QueryEntitiesRequest =
export type QueryEntitiesInitialRequest = {
fields?: string[];
limit?: number;
offset?: number;
filter?: EntityFilterQuery;
orderFields?: EntityOrderQuery;
fullTextFilter?: {
-7
View File
@@ -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),
});
+22 -5
View File
@@ -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>>;
+1
View File
@@ -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],
);
+6
View File
@@ -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 };
+2 -5
View File
@@ -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}
/>
);
}
@@ -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}
@@ -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: '' } }}
/>
);
}