Merge pull request #21202 from backstage/vinzscam/catalog-paginated-table
Catalog: paginated entities
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': patch
|
||||
---
|
||||
|
||||
Added pagination support to `EntityListProvider`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Minor improvements to `Table` component.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
|
||||
Vendored
+27
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user