Merge pull request #21202 from backstage/vinzscam/catalog-paginated-table

Catalog: paginated entities
This commit is contained in:
Patrik Oldsberg
2023-11-21 13:59:36 +01:00
committed by GitHub
18 changed files with 924 additions and 268 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Added pagination support to `EntityListProvider`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Minor improvements to `Table` component.
+20
View File
@@ -0,0 +1,20 @@
---
'@backstage/plugin-catalog': patch
---
Added pagination support to `CatalogIndexPage`
`CatalogIndexPage` now offers an optional pagination feature, designed to accommodate adopters managing extensive catalogs. This new capability allows for better handling of large amounts of data.
To activate the pagination mode, simply update your `App.tsx` as follows:
```diff
const routes = (
<FlatRoutes>
...
- <Route path="/catalog" element={<CatalogIndexPage />} />
+ <Route path="/catalog" element={<CatalogIndexPage pagination />} />
...
```
In case you have a custom catalog page and you want to enable pagination, you need to pass the `pagination` prop to `EntityListProvider` instead.
@@ -38,6 +38,14 @@ auth:
url: https://demo.backstage.io/api/auth
catalog:
rules:
- allow:
- Component
- API
- Resource
- System
- Domain
- Location
locations:
- type: url
target: https://github.com/backstage/backstage/blob/${REF_NAME}/packages/catalog-model/examples/all.yaml
@@ -53,12 +53,13 @@ import React, {
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { SelectProps } from '../Select/Select';
import { Filter, Filters, SelectedFilters, Without } from './Filters';
import CircularProgress from '@material-ui/core/CircularProgress';
import { TableLoadingBody } from './TableLoadingBody';
// Material-table is not using the standard icons available in in material-ui. https://github.com/mbrn/material-table/issues/51
const tableIcons: Icons = {
@@ -308,15 +309,16 @@ export function Table<T extends object = {}>(props: TableProps<T>) {
const {
data,
columns,
emptyContent,
options,
title,
subtitle,
localization,
filters,
initialState,
emptyContent,
onStateChange,
components,
isLoading: isLoading,
isLoading: loading,
...restProps
} = props;
const tableClasses = useTableStyles();
@@ -332,14 +334,11 @@ export function Table<T extends object = {}>(props: TableProps<T>) {
() => setFiltersOpen(v => !v),
[setFiltersOpen],
);
const [selectedFiltersLength, setSelectedFiltersLength] = useState(0);
const [tableData, setTableData] = useState(data as any[]);
const [selectedFilters, setSelectedFilters] = useState(
calculatedInitialState.filters,
);
const MTColumns = convertColumns(columns, theme);
const [search, setSearch] = useState(calculatedInitialState.search);
useEffect(() => {
@@ -357,25 +356,15 @@ export function Table<T extends object = {}>(props: TableProps<T>) {
}
}, [search, filtersOpen, selectedFilters, onStateChange]);
const defaultOptions: Options<T> = {
headerStyle: {
textTransform: 'uppercase',
},
};
const getFieldByTitle = useCallback(
(titleValue: string | keyof T) =>
columns.find(el => el.title === titleValue)?.field,
[columns],
);
useEffect(() => {
if (typeof data === 'function') {
return;
}
if (!selectedFilters) {
setTableData(data as any[]);
return;
const tableData = useMemo(() => {
if (typeof data === 'function' || !selectedFilters) {
return data;
}
const selectedFiltersArray = Object.values(selectedFilters);
@@ -401,62 +390,12 @@ export function Table<T extends object = {}>(props: TableProps<T>) {
return fieldValue === filterValue;
}),
);
setTableData(newData);
} else {
setTableData(data as any[]);
return newData;
}
setSelectedFiltersLength(selectedFiltersArray.flat().length);
return data;
}, [data, selectedFilters, getFieldByTitle]);
const constructFilters = (
filterConfig: TableFilter[],
dataValue: any[] | undefined,
): Filter[] => {
const extractDistinctValues = (field: string | keyof T): Set<any> => {
const distinctValues = new Set<any>();
const addValue = (value: any) => {
if (value !== undefined && value !== null) {
distinctValues.add(value);
}
};
if (dataValue) {
dataValue.forEach(el => {
const value = extractValueByField(
el,
getFieldByTitle(field) as string,
);
if (Array.isArray(value)) {
(value as []).forEach(addValue);
} else {
addValue(value);
}
});
}
return distinctValues;
};
const constructSelect = (
filter: TableFilter,
): Without<SelectProps, 'onChange'> => {
return {
placeholder: 'All results',
label: filter.column,
multiple: filter.type === 'multiple-select',
items: [...extractDistinctValues(filter.column)].sort().map(value => ({
label: value,
value,
})),
};
};
return filterConfig.map(filter => ({
type: filter.type,
element: constructSelect(filter),
}));
};
const selectedFiltersLength = Object.values(selectedFilters).flat().length;
const hasFilters = !!filters?.length;
const Toolbar = useCallback(
@@ -476,50 +415,16 @@ export function Table<T extends object = {}>(props: TableProps<T>) {
const hasNoRows = typeof data !== 'function' && data.length === 0;
const columnCount = columns.length;
const Body = useCallback(
(bodyProps: any /* no type for this in material-table */) => {
if (isLoading) {
return (
<tbody data-testid="loading-indicator">
<tr>
<td colSpan={columnCount}>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
minHeight: '15rem',
}}
>
<CircularProgress size="5rem" />
</Box>
</td>
</tr>
</tbody>
);
}
if (emptyContent && hasNoRows) {
return (
<tbody>
<tr>
<td colSpan={columnCount}>{emptyContent}</td>
</tr>
</tbody>
);
}
return <MTableBody {...bodyProps} />;
},
[hasNoRows, emptyContent, columnCount, isLoading],
const Body = useMemo(
() => makeBody({ hasNoRows, emptyContent, columnCount, loading }),
[hasNoRows, emptyContent, columnCount, loading],
);
return (
<Box className={tableClasses.root}>
{filtersOpen && data && typeof data !== 'function' && filters?.length && (
<Filters
filters={constructFilters(filters, data as any[])}
filters={constructFilters(filters, data as any[], columns)}
selectedFilters={selectedFilters}
onChangeFilters={setSelectedFilters}
/>
@@ -527,12 +432,12 @@ export function Table<T extends object = {}>(props: TableProps<T>) {
<MTable<T>
components={{
Header: StyledMTableHeader,
Toolbar,
Body,
Toolbar,
...components,
}}
options={{ ...defaultOptions, ...options }}
columns={MTColumns}
options={{ headerStyle: { textTransform: 'uppercase' }, ...options }}
columns={convertColumns(columns, theme)}
icons={tableIcons}
title={
<>
@@ -546,10 +451,11 @@ export function Table<T extends object = {}>(props: TableProps<T>) {
)}
</>
}
data={typeof data === 'function' ? data : tableData}
data={tableData}
style={{ width: '100%' }}
localization={{
toolbar: { searchPlaceholder: 'Filter', searchTooltip: 'Filter' },
...localization,
}}
{...restProps}
/>
@@ -558,3 +464,84 @@ export function Table<T extends object = {}>(props: TableProps<T>) {
}
Table.icons = Object.freeze(tableIcons);
function makeBody({
columnCount,
emptyContent,
hasNoRows,
loading,
}: {
hasNoRows: boolean;
emptyContent: ReactNode;
columnCount: number;
loading?: boolean;
}) {
return (bodyProps: any /* no type for this in material-table */) => {
if (loading) {
return <TableLoadingBody colSpan={columnCount} />;
}
if (emptyContent && hasNoRows) {
return (
<tbody>
<tr>
<td colSpan={columnCount}>{emptyContent}</td>
</tr>
</tbody>
);
}
return <MTableBody {...bodyProps} />;
};
}
function constructFilters<T extends object>(
filterConfig: TableFilter[],
dataValue: any[] | undefined,
columns: TableColumn<T>[],
): Filter[] {
const extractDistinctValues = (field: string | keyof T): Set<any> => {
const distinctValues = new Set<any>();
const addValue = (value: any) => {
if (value !== undefined && value !== null) {
distinctValues.add(value);
}
};
if (dataValue) {
dataValue.forEach(el => {
const value = extractValueByField(
el,
columns.find(c => c.title === field)?.field as string,
);
if (Array.isArray(value)) {
(value as []).forEach(addValue);
} else {
addValue(value);
}
});
}
return distinctValues;
};
const constructSelect = (
filter: TableFilter,
): Without<SelectProps, 'onChange'> => {
return {
placeholder: 'All results',
label: filter.column,
multiple: filter.type === 'multiple-select',
items: [...extractDistinctValues(filter.column)].sort().map(value => ({
label: value,
value,
})),
};
};
return filterConfig.map(filter => ({
type: filter.type,
element: constructSelect(filter),
}));
}
@@ -0,0 +1,44 @@
/*
* 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 Box from '@material-ui/core/Box';
import CircularProgress from '@material-ui/core/CircularProgress';
import React from 'react';
/**
* @internal
*/
export function TableLoadingBody(props: { colSpan?: number }) {
return (
<tbody data-testid="loading-indicator">
<tr>
<td colSpan={props.colSpan}>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
minHeight: '15rem',
}}
>
<CircularProgress size="5rem" />
</Box>
</td>
</tr>
</tbody>
);
}
+14 -1
View File
@@ -284,13 +284,26 @@ export type EntityListContextProps<
queryParameters: Partial<Record<keyof EntityFilters, string | string[]>>;
loading: boolean;
error?: Error;
pageInfo?: {
next?: () => void;
prev?: () => void;
};
};
// @public
export const EntityListProvider: <EntityFilters extends DefaultEntityFilters>(
props: PropsWithChildren<{}>,
props: EntityListProviderProps,
) => React_2.JSX.Element;
// @public (undocumented)
export type EntityListProviderProps = PropsWithChildren<{
pagination?:
| boolean
| {
limit?: number;
};
}>;
// @public (undocumented)
export type EntityLoadingStatus<TEntity extends Entity = Entity> = {
entity?: TEntity;
+1
View File
@@ -32,6 +32,7 @@ export {
export type {
DefaultEntityFilters,
EntityListContextProps,
EntityListProviderProps,
} from './useEntityListProvider';
export { useEntityTypeFilter } from './useEntityTypeFilter';
export { useRelatedEntities } from './useRelatedEntities';
@@ -31,14 +31,13 @@ import React, { PropsWithChildren } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { catalogApiRef } from '../api';
import { starredEntitiesApiRef, MockStarredEntitiesApi } from '../apis';
import { EntityKindPicker, UserListPicker } from '../components';
import {
EntityKindFilter,
EntityTypeFilter,
EntityUserFilter,
} from '../filters';
import { UserListFilterKind } from '../types';
import { EntityListProvider, useEntityList } from './useEntityListProvider';
import { useMountEffect } from '@react-hookz/web';
const entities: Entity[] = [
{
@@ -77,43 +76,53 @@ const mockIdentityApi: Partial<IdentityApi> = {
}),
getCredentials: async () => ({ token: undefined }),
};
const mockCatalogApi: Partial<CatalogApi> = {
getEntities: jest.fn().mockImplementation(async () => ({ items: entities })),
getEntityByRef: async () => undefined,
const mockCatalogApi: Partial<jest.Mocked<CatalogApi>> = {
getEntities: jest.fn().mockResolvedValue({ items: entities }),
queryEntities: jest.fn().mockResolvedValue({
items: entities,
pageInfo: { prevCursor: 'prevCursor', nextCursor: 'nextCursor' },
totalItems: 10,
}),
getEntityByRef: jest.fn().mockResolvedValue(undefined),
};
const wrapper = ({
userFilter,
location,
children,
}: PropsWithChildren<{
userFilter?: UserListFilterKind;
location?: string;
}>) => {
return (
<MemoryRouter initialEntries={[location ?? '']}>
<TestApiProvider
apis={[
[configApiRef, mockConfigApi],
[catalogApiRef, mockCatalogApi],
[identityApiRef, mockIdentityApi],
[storageApiRef, MockStorageApi.create()],
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
[alertApiRef, { post: jest.fn() }],
]}
>
<EntityListProvider>
<EntityKindPicker initialFilter="component" hidden />
<UserListPicker initialFilter={userFilter} />
{children}
</EntityListProvider>
</TestApiProvider>
</MemoryRouter>
);
};
const createWrapper =
(options: { location?: string; pagination: boolean }) =>
(props: PropsWithChildren) => {
const InitialFiltersWrapper = ({ children }: PropsWithChildren) => {
const { updateFilters } = useEntityList();
useMountEffect(() => {
updateFilters({ kind: new EntityKindFilter('component') });
});
return <>{children}</>;
};
return (
<MemoryRouter initialEntries={[options.location ?? '']}>
<TestApiProvider
apis={[
[configApiRef, mockConfigApi],
[catalogApiRef, mockCatalogApi],
[identityApiRef, mockIdentityApi],
[storageApiRef, MockStorageApi.create()],
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
[alertApiRef, { post: jest.fn() }],
]}
>
<EntityListProvider pagination={options.pagination}>
<InitialFiltersWrapper>{props.children}</InitialFiltersWrapper>
</EntityListProvider>
</TestApiProvider>
</MemoryRouter>
);
};
describe('<EntityListProvider />', () => {
const origReplaceState = window.history.replaceState;
const pagination = false;
beforeEach(() => {
window.history.replaceState = jest.fn();
});
@@ -125,16 +134,17 @@ describe('<EntityListProvider />', () => {
jest.clearAllMocks();
});
it('resolves backend filters', async () => {
it('should send backend filters', async () => {
const { result } = renderHook(() => useEntityList(), {
wrapper,
wrapper: createWrapper({ pagination }),
});
await waitFor(() => {
expect(result.current.backendEntities.length).toBeGreaterThan(0);
expect(result.current.backendEntities.length).toBe(2);
});
expect(result.current.backendEntities.length).toBe(2);
expect(result.current.entities.length).toBe(2);
expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
expect(mockCatalogApi.getEntities).toHaveBeenCalledWith({
filter: { kind: 'component' },
});
@@ -142,17 +152,12 @@ describe('<EntityListProvider />', () => {
it('resolves frontend filters', async () => {
const { result } = renderHook(() => useEntityList(), {
wrapper,
wrapper: createWrapper({ pagination }),
initialProps: {
userFilter: 'all',
},
});
await waitFor(() => {
expect(result.current.backendEntities.length).toBeGreaterThan(0);
});
expect(result.current.backendEntities.length).toBe(2);
act(() =>
result.current.updateFilters({
user: EntityUserFilter.owned(ownershipEntityRefs),
@@ -171,8 +176,10 @@ describe('<EntityListProvider />', () => {
filters: { kind: 'component', type: 'service' },
});
const { result } = renderHook(() => useEntityList(), {
wrapper: ({ children }) =>
wrapper({ location: `/catalog?${query}`, children }),
wrapper: createWrapper({
location: `/catalog?${query}`,
pagination,
}),
});
await waitFor(() => {
@@ -186,7 +193,7 @@ describe('<EntityListProvider />', () => {
it('does not fetch when only frontend filters change', async () => {
const { result } = renderHook(() => useEntityList(), {
wrapper,
wrapper: createWrapper({ pagination }),
});
await waitFor(() => {
@@ -202,13 +209,18 @@ describe('<EntityListProvider />', () => {
await waitFor(() => {
expect(result.current.entities.length).toBe(1);
expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
});
await expect(() =>
waitFor(() => {
expect(mockCatalogApi.getEntities).not.toHaveBeenCalledTimes(1);
}),
).rejects.toThrow();
});
it('debounces multiple filter changes', async () => {
const { result } = renderHook(() => useEntityList(), {
wrapper,
wrapper: createWrapper({ pagination }),
});
await waitFor(() => {
@@ -218,18 +230,20 @@ describe('<EntityListProvider />', () => {
expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
await act(async () => {
result.current.updateFilters({ kind: new EntityKindFilter('component') });
result.current.updateFilters({ kind: new EntityKindFilter('api') });
result.current.updateFilters({ type: new EntityTypeFilter('service') });
});
await waitFor(() => {
expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(2);
expect(mockCatalogApi.getEntities).toHaveBeenNthCalledWith(2, {
filter: { kind: 'api', 'spec.type': ['service'] },
});
});
});
it('returns an error on catalogApi failure', async () => {
const { result } = renderHook(() => useEntityList(), {
wrapper,
wrapper: createWrapper({ pagination }),
});
await waitFor(() => {
@@ -237,7 +251,7 @@ describe('<EntityListProvider />', () => {
});
expect(result.current.backendEntities.length).toBe(2);
mockCatalogApi.getEntities = jest.fn().mockRejectedValue('error');
mockCatalogApi.getEntities!.mockRejectedValueOnce('error');
act(() => {
result.current.updateFilters({ kind: new EntityKindFilter('api') });
});
@@ -245,4 +259,230 @@ describe('<EntityListProvider />', () => {
expect(result.current.error).toBeDefined();
});
});
it('returns an empty pageInfo', async () => {
const { result } = renderHook(() => useEntityList(), {
wrapper: createWrapper({ pagination }),
});
await waitFor(() => {
expect(mockCatalogApi.getEntities).toHaveBeenCalled();
});
expect(result.current.pageInfo).toBeUndefined();
});
});
describe('<EntityListProvider pagination />', () => {
const origReplaceState = window.history.replaceState;
const pagination = true;
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('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('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('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();
});
});
describe('pageInfo', () => {
it('returns an empty pageInfo', async () => {
mockCatalogApi.queryEntities!.mockResolvedValueOnce({
items: [],
pageInfo: {},
totalItems: 10,
});
const { result } = renderHook(() => useEntityList(), {
wrapper: createWrapper({ pagination }),
});
await waitFor(() => {
expect(mockCatalogApi.queryEntities).toHaveBeenCalled();
});
expect(result.current.pageInfo).toStrictEqual({
prev: undefined,
next: undefined,
});
});
it('returns pageInfo with next function and properly fetch next batch', async () => {
const { result } = renderHook(() => useEntityList(), {
wrapper: createWrapper({ pagination }),
});
await waitFor(() => {
expect(mockCatalogApi.queryEntities).toHaveBeenCalled();
});
await waitFor(() => {
expect(result.current.pageInfo!.next).toBeDefined();
});
act(() => {
result.current.pageInfo!.next!();
});
await waitFor(() => {
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
cursor: 'nextCursor',
limit,
});
});
});
it('returns pageInfo with prev function and properly fetch prev batch', async () => {
const { result } = renderHook(() => useEntityList(), {
wrapper: createWrapper({ pagination }),
});
await waitFor(() => {
expect(mockCatalogApi.queryEntities).toHaveBeenCalled();
});
await waitFor(() => {
expect(result.current.pageInfo!.prev).toBeDefined();
});
act(() => {
result.current.pageInfo!.prev!();
});
await waitFor(() => {
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
cursor: 'prevCursor',
limit,
});
});
});
});
});
@@ -44,8 +44,13 @@ import {
EntityUserFilter,
} from '../filters';
import { EntityFilter } from '../types';
import { reduceBackendCatalogFilters, reduceEntityFilters } from '../utils';
import {
reduceBackendCatalogFilters,
reduceCatalogFilters,
reduceEntityFilters,
} from '../utils';
import { useApi } from '@backstage/core-plugin-api';
import { QueryEntitiesResponse } from '@backstage/catalog-client';
/** @public */
export type DefaultEntityFilters = {
@@ -98,6 +103,11 @@ export type EntityListContextProps<
loading: boolean;
error?: Error;
pageInfo?: {
next?: () => void;
prev?: () => void;
};
};
/**
@@ -110,16 +120,25 @@ export const EntityListContext = createContext<
type OutputState<EntityFilters extends DefaultEntityFilters> = {
appliedFilters: EntityFilters;
appliedCursor?: string;
entities: Entity[];
backendEntities: Entity[];
pageInfo?: QueryEntitiesResponse['pageInfo'];
};
/**
* @public
*/
export type EntityListProviderProps = PropsWithChildren<{
pagination?: boolean | { limit?: number };
}>;
/**
* Provides entities and filters for a catalog listing.
* @public
*/
export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
props: PropsWithChildren<{}>,
props: EntityListProviderProps,
) => {
const isMounted = useMountedState();
const catalogApi = useApi(catalogApiRef);
@@ -132,13 +151,32 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
// trigger a useLocation change; this would instead come from an external source, such as a manual
// update of the URL or two catalog sidebar links with different catalog filters.
const location = useLocation();
const queryParameters = useMemo(
() =>
(qs.parse(location.search, {
ignoreQueryPrefix: true,
}).filters ?? {}) as Record<string, string | string[]>,
[location],
);
const enablePagination =
props.pagination === true || typeof props.pagination === 'object';
const limit =
props.pagination &&
typeof props.pagination === 'object' &&
typeof props.pagination.limit === 'number'
? props.pagination.limit
: 20;
const { queryParameters, cursor: initialCursor } = useMemo(() => {
const parsed = qs.parse(location.search, {
ignoreQueryPrefix: true,
});
return {
queryParameters: (parsed.filters ?? {}) as Record<
string,
string | string[]
>,
cursor: typeof parsed.cursor === 'string' ? parsed.cursor : undefined,
};
}, [location]);
const [cursor, setCursor] = useState(initialCursor);
const [outputState, setOutputState] = useState<OutputState<EntityFilters>>(
() => {
@@ -146,6 +184,7 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
appliedFilters: {} as EntityFilters,
entities: [],
backendEntities: [],
pageInfo: enablePagination ? {} : undefined,
};
},
);
@@ -156,11 +195,6 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
const [{ loading, error }, refresh] = useAsyncFn(
async () => {
const compacted = compact(Object.values(requestedFilters));
const entityFilter = reduceEntityFilters(compacted);
const backendFilter = reduceBackendCatalogFilters(compacted);
const previousBackendFilter = reduceBackendCatalogFilters(
compact(Object.values(outputState.appliedFilters)),
);
const queryParams = Object.keys(requestedFilters).reduce(
(params, key) => {
@@ -175,26 +209,71 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
{} as Record<string, string | string[]>,
);
// TODO(mtlewis): currently entities will never be requested unless
// there's at least one filter, we should allow an initial request
// to happen with no filters.
if (!isEqual(previousBackendFilter, backendFilter)) {
// TODO(timbonicus): should limit fields here, but would need filter
// fields + table columns
const response = await catalogApi.getEntities({
filter: backendFilter,
});
setOutputState({
appliedFilters: requestedFilters,
backendEntities: response.items,
entities: response.items.filter(entityFilter),
});
if (enablePagination) {
if (cursor) {
if (cursor !== outputState.appliedCursor) {
const entityFilter = reduceEntityFilters(compacted);
const response = await catalogApi.queryEntities({
cursor,
limit,
});
setOutputState({
appliedFilters: requestedFilters,
appliedCursor: cursor,
backendEntities: response.items,
entities: response.items.filter(entityFilter),
pageInfo: response.pageInfo,
});
}
} else {
const entityFilter = reduceEntityFilters(compacted);
const backendFilter = reduceCatalogFilters(compacted);
const previousBackendFilter = reduceCatalogFilters(
compact(Object.values(outputState.appliedFilters)),
);
if (!isEqual(previousBackendFilter, backendFilter)) {
const response = await catalogApi.queryEntities({
filter: backendFilter,
limit,
orderFields: [{ field: 'metadata.name', order: 'asc' }],
});
setOutputState({
appliedFilters: requestedFilters,
backendEntities: response.items,
entities: response.items.filter(entityFilter),
pageInfo: response.pageInfo,
});
}
}
} else {
setOutputState({
appliedFilters: requestedFilters,
backendEntities: outputState.backendEntities,
entities: outputState.backendEntities.filter(entityFilter),
});
const entityFilter = reduceEntityFilters(compacted);
const backendFilter = reduceBackendCatalogFilters(compacted);
const previousBackendFilter = reduceBackendCatalogFilters(
compact(Object.values(outputState.appliedFilters)),
);
// TODO(mtlewis): currently entities will never be requested unless
// there's at least one filter, we should allow an initial request
// to happen with no filters.
if (!isEqual(previousBackendFilter, backendFilter)) {
// TODO(timbonicus): should limit fields here, but would need filter
// fields + table columns
const response = await catalogApi.getEntities({
filter: backendFilter,
});
setOutputState({
appliedFilters: requestedFilters,
backendEntities: response.items,
entities: response.items.filter(entityFilter),
});
} else {
setOutputState({
appliedFilters: requestedFilters,
backendEntities: outputState.backendEntities,
entities: outputState.backendEntities.filter(entityFilter),
});
}
}
if (isMounted()) {
@@ -202,7 +281,7 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
ignoreQueryPrefix: true,
});
const newParams = qs.stringify(
{ ...oldParams, filters: queryParams },
{ ...oldParams, filters: queryParams, cursor },
{ addQueryPrefix: true, arrayFormat: 'repeat' },
);
const newUrl = `${window.location.pathname}${newParams}`;
@@ -214,13 +293,20 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
window.history?.replaceState(null, document.title, newUrl);
}
},
[catalogApi, queryParameters, requestedFilters, outputState],
[
catalogApi,
queryParameters,
requestedFilters,
outputState,
cursor,
enablePagination,
],
{ 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]);
useDebounce(refresh, 10, [requestedFilters, cursor]);
const updateFilters = useCallback(
(
@@ -228,6 +314,12 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
| Partial<EntityFilter>
| ((prevFilters: EntityFilters) => Partial<EntityFilters>),
) => {
// changing filters will affect pagination, so we need to reset
// the cursor and start from the first page.
// TODO(vinzscam): this is currently causing issues at page reload
// where the state is not kept. Unfortunately we need to rething
// the way filters work in order to fix this.
setCursor(undefined);
setRequestedFilters(prevFilters => {
const newFilters =
typeof update === 'function' ? update(prevFilters) : update;
@@ -237,6 +329,19 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
[],
);
const pageInfo = useMemo(() => {
if (!enablePagination) {
return undefined;
}
const prevCursor = outputState.pageInfo?.prevCursor;
const nextCursor = outputState.pageInfo?.nextCursor;
return {
prev: prevCursor ? () => setCursor(prevCursor) : undefined,
next: nextCursor ? () => setCursor(nextCursor) : undefined,
};
}, [enablePagination, outputState.pageInfo]);
const value = useMemo(
() => ({
filters: outputState.appliedFilters,
@@ -246,8 +351,9 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
queryParameters,
loading,
error,
pageInfo,
}),
[outputState, updateFilters, queryParameters, loading, error],
[outputState, updateFilters, queryParameters, loading, error, pageInfo],
);
return (
+9 -1
View File
@@ -158,7 +158,9 @@ export const CatalogTable: {
createSystemColumn(): TableColumn<CatalogTableRow>;
createOwnerColumn(): TableColumn<CatalogTableRow>;
createSpecTargetsColumn(): TableColumn<CatalogTableRow>;
createSpecTypeColumn(): TableColumn<CatalogTableRow>;
createSpecTypeColumn(options?: {
hidden: boolean;
}): TableColumn<CatalogTableRow>;
createSpecLifecycleColumn(): TableColumn<CatalogTableRow>;
createMetadataDescriptionColumn(): TableColumn<CatalogTableRow>;
createTagsColumn(): TableColumn<CatalogTableRow>;
@@ -234,6 +236,12 @@ export interface DefaultCatalogPageProps {
// (undocumented)
ownerPickerMode?: EntityOwnerPickerProps['mode'];
// (undocumented)
pagination?:
| boolean
| {
limit?: number;
};
// (undocumented)
tableOptions?: TableProps<CatalogTableRow>['options'];
}
+27
View File
@@ -0,0 +1,27 @@
/*
* 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.
*/
export interface Config {
catalog?: {
/**
* @deepVisibility frontend
*/
experimentalPagination?:
| boolean
| {
limit?: number;
};
};
}
+4 -2
View File
@@ -91,6 +91,8 @@
"@testing-library/user-event": "^14.0.0"
},
"files": [
"dist"
]
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
}
@@ -47,14 +47,15 @@ import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { CatalogTableColumnsFunc } from '../CatalogTable/types';
/** @internal */
export interface BaseCatalogPageProps {
export type BaseCatalogPageProps = {
filters: ReactNode;
content?: ReactNode;
}
pagination?: boolean | { limit?: number };
};
/** @internal */
export function BaseCatalogPage(props: BaseCatalogPageProps) {
const { filters, content = <CatalogTable /> } = props;
const { filters, content = <CatalogTable />, pagination } = props;
const orgName =
useApi(configApiRef).getOptionalString('organization.name') ?? 'Backstage';
const createComponentLink = useRouteRef(createComponentRouteRef);
@@ -70,7 +71,7 @@ export function BaseCatalogPage(props: BaseCatalogPageProps) {
/>
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<EntityListProvider>
<EntityListProvider pagination={pagination}>
<CatalogFilterLayout>
<CatalogFilterLayout.Filters>{filters}</CatalogFilterLayout.Filters>
<CatalogFilterLayout.Content>{content}</CatalogFilterLayout.Content>
@@ -94,6 +95,7 @@ export interface DefaultCatalogPageProps {
tableOptions?: TableProps<CatalogTableRow>['options'];
emptyContent?: ReactNode;
ownerPickerMode?: EntityOwnerPickerProps['mode'];
pagination?: boolean | { limit?: number };
}
export function DefaultCatalogPage(props: DefaultCatalogPageProps) {
@@ -104,6 +106,7 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) {
initialKind = 'component',
tableOptions = {},
emptyContent,
pagination,
ownerPickerMode,
} = props;
@@ -129,6 +132,7 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) {
emptyContent={emptyContent}
/>
}
pagination={pagination}
/>
);
}
@@ -46,6 +46,7 @@ import pluralize from 'pluralize';
import React, { ReactNode, useMemo } from 'react';
import { columnFactories } from './columns';
import { CatalogTableColumnsFunc, CatalogTableRow } from './types';
import { PaginatedCatalogTable } from './PaginatedCatalogTable';
/**
* Props for {@link CatalogTable}.
@@ -77,6 +78,8 @@ const refCompare = (a: Entity, b: Entity) => {
};
const defaultColumnsFunc: CatalogTableColumnsFunc = ({ filters, entities }) => {
const showTypeColumn = filters.type === undefined;
return [
columnFactories.createTitleColumn({ hidden: true }),
columnFactories.createNameColumn({ defaultKind: filters.kind?.value }),
@@ -89,7 +92,7 @@ const defaultColumnsFunc: CatalogTableColumnsFunc = ({ filters, entities }) => {
const baseColumns = [
columnFactories.createSystemColumn(),
columnFactories.createOwnerColumn(),
columnFactories.createSpecTypeColumn(),
columnFactories.createSpecTypeColumn({ hidden: !showTypeColumn }),
columnFactories.createSpecLifecycleColumn(),
];
switch (filters.kind?.value) {
@@ -100,10 +103,12 @@ const defaultColumnsFunc: CatalogTableColumnsFunc = ({ filters, entities }) => {
return [columnFactories.createOwnerColumn()];
case 'group':
case 'template':
return [columnFactories.createSpecTypeColumn()];
return [
columnFactories.createSpecTypeColumn({ hidden: !showTypeColumn }),
];
case 'location':
return [
columnFactories.createSpecTypeColumn(),
columnFactories.createSpecTypeColumn({ hidden: !showTypeColumn }),
columnFactories.createSpecTargetsColumn(),
];
default:
@@ -118,14 +123,14 @@ const defaultColumnsFunc: CatalogTableColumnsFunc = ({ filters, entities }) => {
export const CatalogTable = (props: CatalogTableProps) => {
const {
columns = defaultColumnsFunc,
actions,
tableOptions,
subtitle,
emptyContent,
} = props;
const { isStarredEntity, toggleStarredEntity } = useStarredEntities();
const entityListContext = useEntityList();
const { loading, error, entities, filters } = entityListContext;
const { loading, error, entities, filters, pageInfo } = entityListContext;
const enablePagination = !!pageInfo;
const tableColumns = useMemo(
() =>
@@ -133,10 +138,6 @@ export const CatalogTable = (props: CatalogTableProps) => {
[columns, entityListContext],
);
const showTypeColumn = filters.type === undefined;
// TODO(timbonicus): remove the title from the CatalogTable once using EntitySearchBar
const titlePreamble = capitalize(filters.user?.value ?? 'all');
if (error) {
return (
<div>
@@ -207,66 +208,58 @@ export const CatalogTable = (props: CatalogTableProps) => {
},
];
const rows = entities.sort(refCompare).map(entity => {
const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, {
kind: 'system',
});
const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY);
return {
entity,
resolved: {
// This name is here for backwards compatibility mostly; the
// presentation of refs in the table should in general be handled with
// EntityRefLink / EntityName components
name: humanizeEntityRef(entity, {
defaultKind: 'Component',
}),
entityRef: stringifyEntityRef(entity),
ownedByRelationsTitle: ownedByRelations
.map(r => humanizeEntityRef(r, { defaultKind: 'group' }))
.join(', '),
ownedByRelations,
partOfSystemRelationTitle: partOfSystemRelations
.map(r =>
humanizeEntityRef(r, {
defaultKind: 'system',
}),
)
.join(', '),
partOfSystemRelations,
},
};
});
const typeColumn = tableColumns.find(c => c.title === 'Type');
if (typeColumn) {
typeColumn.hidden = !showTypeColumn;
}
const showPagination = rows.length > 20;
const currentKind = filters.kind?.value || '';
const currentType = filters.type?.value || '';
// TODO(timbonicus): remove the title from the CatalogTable once using EntitySearchBar
const titlePreamble = capitalize(filters.user?.value ?? 'all');
const titleDisplay = [titlePreamble, currentType, pluralize(currentKind)]
.filter(s => s)
.join(' ');
const title = `${titleDisplay} (${entities.length})`;
const actions = props.actions || defaultActions;
const options = {
actionsColumnIndex: -1,
loadingType: 'linear' as const,
showEmptyDataSourceMessage: !loading,
padding: 'dense' as const,
...tableOptions,
};
if (enablePagination) {
return (
<PaginatedCatalogTable
columns={tableColumns}
emptyContent={emptyContent}
isLoading={loading}
title={title}
actions={actions}
subtitle={subtitle}
options={options}
data={entities.map(toEntityRow)}
next={pageInfo.next}
prev={pageInfo.prev}
/>
);
}
const rows = entities.sort(refCompare).map(toEntityRow);
const pageSize = 20;
const showPagination = rows.length > pageSize;
return (
<Table<CatalogTableRow>
isLoading={loading}
columns={tableColumns}
options={{
paging: showPagination,
pageSize: 20,
actionsColumnIndex: -1,
loadingType: 'linear',
showEmptyDataSourceMessage: !loading,
padding: 'dense',
pageSize: pageSize,
pageSizeOptions: [20, 50, 100],
...tableOptions,
...options,
}}
title={`${titleDisplay} (${entities.length})`}
data={rows}
actions={actions || defaultActions}
actions={actions}
subtitle={subtitle}
emptyContent={emptyContent}
/>
@@ -274,3 +267,35 @@ export const CatalogTable = (props: CatalogTableProps) => {
};
CatalogTable.columns = columnFactories;
function toEntityRow(entity: Entity) {
const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, {
kind: 'system',
});
const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY);
return {
entity,
resolved: {
// This name is here for backwards compatibility mostly; the
// presentation of refs in the table should in general be handled with
// EntityRefLink / EntityName components
name: humanizeEntityRef(entity, {
defaultKind: 'Component',
}),
entityRef: stringifyEntityRef(entity),
ownedByRelationsTitle: ownedByRelations
.map(r => humanizeEntityRef(r, { defaultKind: 'group' }))
.join(', '),
ownedByRelations,
partOfSystemRelationTitle: partOfSystemRelations
.map(r =>
humanizeEntityRef(r, {
defaultKind: 'system',
}),
)
.join(', '),
partOfSystemRelations,
},
};
}
@@ -0,0 +1,99 @@
/*
* 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 from 'react';
import { fireEvent, render } from '@testing-library/react';
import { PaginatedCatalogTable } from './PaginatedCatalogTable';
import { screen } from '@testing-library/react';
import { CatalogTableRow } from './types';
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,
},
];
it('should display all the items', () => {
render(<PaginatedCatalogTable data={data} columns={columns} />);
for (const item of data) {
expect(screen.queryByText(item.resolved.name)).toBeInTheDocument();
}
});
it('should display and invoke the next button', async () => {
const { rerender } = render(
<PaginatedCatalogTable data={data} columns={columns} next={undefined} />,
);
expect(
screen.queryAllByRole('button', { name: 'Next Page' })[0],
).toBeDisabled();
const fn = jest.fn();
rerender(<PaginatedCatalogTable data={data} columns={columns} next={fn} />);
const nextButton = screen.queryAllByRole('button', {
name: 'Next Page',
})[0];
expect(nextButton).toBeEnabled();
fireEvent.click(nextButton);
expect(fn).toHaveBeenCalled();
});
it('should display and invoke the prev button', async () => {
const { rerender } = render(
<PaginatedCatalogTable data={data} columns={columns} prev={undefined} />,
);
expect(
screen.queryAllByRole('button', { name: 'Next Page' })[0],
).toBeDisabled();
const fn = jest.fn();
rerender(<PaginatedCatalogTable data={data} columns={columns} prev={fn} />);
const prevButton = screen.queryAllByRole('button', {
name: 'Previous Page',
})[0];
expect(prevButton).toBeEnabled();
fireEvent.click(prevButton);
expect(fn).toHaveBeenCalled();
});
});
@@ -0,0 +1,58 @@
/*
* 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 from 'react';
import { Table, TableProps } from '@backstage/core-components';
import { CatalogTableRow } from './types';
type PaginatedCatalogTableProps = {
prev?(): void;
next?(): void;
} & TableProps<CatalogTableRow>;
/**
* @internal
*/
export function PaginatedCatalogTable(props: PaginatedCatalogTableProps) {
const { columns, data, next, prev } = props;
return (
<Table
columns={columns}
data={data}
options={{
paginationPosition: 'both',
pageSizeOptions: [],
showFirstLastPageButtons: false,
pageSize: Number.MAX_SAFE_INTEGER,
emptyRowsWhenPaging: false,
}}
onPageChange={page => {
if (page > 0) {
next?.();
} else {
prev?.();
}
}}
/* this will enable the prev button accordingly */
page={prev ? 1 : 0}
/* this will enable the next button accordingly */
totalCount={next ? Number.MAX_VALUE : Number.MAX_SAFE_INTEGER}
localization={{ pagination: { labelDisplayedRows: '' } }}
/>
);
}
@@ -100,11 +100,15 @@ export const columnFactories = Object.freeze({
),
};
},
createSpecTypeColumn(): TableColumn<CatalogTableRow> {
createSpecTypeColumn(
options: {
hidden: boolean;
} = { hidden: false },
): TableColumn<CatalogTableRow> {
return {
title: 'Type',
field: 'entity.spec.type',
hidden: true,
hidden: options.hidden,
width: 'auto',
};
},