diff --git a/.changeset/breezy-pans-glow.md b/.changeset/breezy-pans-glow.md new file mode 100644 index 0000000000..026465aef7 --- /dev/null +++ b/.changeset/breezy-pans-glow.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Added pagination support to `EntityListProvider`. diff --git a/.changeset/eleven-ants-pretend.md b/.changeset/eleven-ants-pretend.md new file mode 100644 index 0000000000..6b188117ba --- /dev/null +++ b/.changeset/eleven-ants-pretend.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Minor improvements to `Table` component. diff --git a/.changeset/silly-numbers-wash.md b/.changeset/silly-numbers-wash.md new file mode 100644 index 0000000000..25df8b12c1 --- /dev/null +++ b/.changeset/silly-numbers-wash.md @@ -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 = ( + + ... +- } /> ++ } /> + ... +``` + +In case you have a custom catalog page and you want to enable pagination, you need to pass the `pagination` prop to `EntityListProvider` instead. diff --git a/.github/uffizzi/uffizzi.production.app-config.yaml b/.github/uffizzi/uffizzi.production.app-config.yaml index fb6638295e..53944256e1 100644 --- a/.github/uffizzi/uffizzi.production.app-config.yaml +++ b/.github/uffizzi/uffizzi.production.app-config.yaml @@ -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 diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index c529c757bc..4aba7445b3 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -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(props: TableProps) { 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(props: TableProps) { () => 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(props: TableProps) { } }, [search, filtersOpen, selectedFilters, onStateChange]); - const defaultOptions: Options = { - 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(props: TableProps) { 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 => { - const distinctValues = new Set(); - 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 => { - 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(props: TableProps) { 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 ( - - - - - - - - - - ); - } - - if (emptyContent && hasNoRows) { - return ( - - - {emptyContent} - - - ); - } - - return ; - }, - [hasNoRows, emptyContent, columnCount, isLoading], + const Body = useMemo( + () => makeBody({ hasNoRows, emptyContent, columnCount, loading }), + [hasNoRows, emptyContent, columnCount, loading], ); return ( {filtersOpen && data && typeof data !== 'function' && filters?.length && ( @@ -527,12 +432,12 @@ export function Table(props: TableProps) { 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(props: TableProps) { )} } - 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(props: TableProps) { } 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 ; + } + + if (emptyContent && hasNoRows) { + return ( + + + {emptyContent} + + + ); + } + + return ; + }; +} + +function constructFilters( + filterConfig: TableFilter[], + dataValue: any[] | undefined, + columns: TableColumn[], +): Filter[] { + const extractDistinctValues = (field: string | keyof T): Set => { + const distinctValues = new Set(); + 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 => { + 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), + })); +} diff --git a/packages/core-components/src/components/Table/TableLoadingBody.tsx b/packages/core-components/src/components/Table/TableLoadingBody.tsx new file mode 100644 index 0000000000..3671bf1c51 --- /dev/null +++ b/packages/core-components/src/components/Table/TableLoadingBody.tsx @@ -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 ( + + + + + + + + + + ); +} diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index e84fa9815c..8c6b3a729f 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -284,13 +284,26 @@ export type EntityListContextProps< queryParameters: Partial>; loading: boolean; error?: Error; + pageInfo?: { + next?: () => void; + prev?: () => void; + }; }; // @public export const EntityListProvider: ( - props: PropsWithChildren<{}>, + props: EntityListProviderProps, ) => React_2.JSX.Element; +// @public (undocumented) +export type EntityListProviderProps = PropsWithChildren<{ + pagination?: + | boolean + | { + limit?: number; + }; +}>; + // @public (undocumented) export type EntityLoadingStatus = { entity?: TEntity; diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index ffd32206ef..c3021f8992 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -32,6 +32,7 @@ export { export type { DefaultEntityFilters, EntityListContextProps, + EntityListProviderProps, } from './useEntityListProvider'; export { useEntityTypeFilter } from './useEntityTypeFilter'; export { useRelatedEntities } from './useRelatedEntities'; diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index edbfb65c0c..f6881d4a80 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -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 = { }), getCredentials: async () => ({ token: undefined }), }; -const mockCatalogApi: Partial = { - getEntities: jest.fn().mockImplementation(async () => ({ items: entities })), - getEntityByRef: async () => undefined, +const mockCatalogApi: Partial> = { + 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 ( - - - - - - - ); -}; +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 ( + + + + {props.children} + + + + ); + }; describe('', () => { const origReplaceState = window.history.replaceState; + const pagination = false; + beforeEach(() => { window.history.replaceState = jest.fn(); }); @@ -125,16 +134,17 @@ describe('', () => { 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('', () => { 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('', () => { 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('', () => { 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('', () => { 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('', () => { 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('', () => { }); 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('', () => { 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('', () => { + 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, + }); + }); + }); + }); }); diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index 948efc969f..1ce87c262e 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -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 = { 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 = ( - props: PropsWithChildren<{}>, + props: EntityListProviderProps, ) => { const isMounted = useMountedState(); const catalogApi = useApi(catalogApiRef); @@ -132,13 +151,32 @@ export const EntityListProvider = ( // 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, - [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>( () => { @@ -146,6 +184,7 @@ export const EntityListProvider = ( appliedFilters: {} as EntityFilters, entities: [], backendEntities: [], + pageInfo: enablePagination ? {} : undefined, }; }, ); @@ -156,11 +195,6 @@ export const EntityListProvider = ( 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 = ( {} as Record, ); - // 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 = ( 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 = ( 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 = ( | Partial | ((prevFilters: EntityFilters) => Partial), ) => { + // 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 = ( [], ); + 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 = ( queryParameters, loading, error, + pageInfo, }), - [outputState, updateFilters, queryParameters, loading, error], + [outputState, updateFilters, queryParameters, loading, error, pageInfo], ); return ( diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index e3df51deab..bef24b50d8 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -158,7 +158,9 @@ export const CatalogTable: { createSystemColumn(): TableColumn; createOwnerColumn(): TableColumn; createSpecTargetsColumn(): TableColumn; - createSpecTypeColumn(): TableColumn; + createSpecTypeColumn(options?: { + hidden: boolean; + }): TableColumn; createSpecLifecycleColumn(): TableColumn; createMetadataDescriptionColumn(): TableColumn; createTagsColumn(): TableColumn; @@ -234,6 +236,12 @@ export interface DefaultCatalogPageProps { // (undocumented) ownerPickerMode?: EntityOwnerPickerProps['mode']; // (undocumented) + pagination?: + | boolean + | { + limit?: number; + }; + // (undocumented) tableOptions?: TableProps['options']; } diff --git a/plugins/catalog/config.d.ts b/plugins/catalog/config.d.ts new file mode 100644 index 0000000000..af749db905 --- /dev/null +++ b/plugins/catalog/config.d.ts @@ -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; + }; + }; +} diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 150f5362f3..827601969a 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -91,6 +91,8 @@ "@testing-library/user-event": "^14.0.0" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx index a50cd4fa2a..1f724d10fc 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx @@ -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 = } = props; + const { filters, content = , pagination } = props; const orgName = useApi(configApiRef).getOptionalString('organization.name') ?? 'Backstage'; const createComponentLink = useRouteRef(createComponentRouteRef); @@ -70,7 +71,7 @@ export function BaseCatalogPage(props: BaseCatalogPageProps) { /> All your software catalog entities - + {filters} {content} @@ -94,6 +95,7 @@ export interface DefaultCatalogPageProps { tableOptions?: TableProps['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} /> ); } diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 4dd3fa257d..006210bb1d 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -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 (
@@ -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 ( + + ); + } + + const rows = entities.sort(refCompare).map(toEntityRow); + const pageSize = 20; + const showPagination = rows.length > pageSize; + return ( 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, + }, + }; +} diff --git a/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.test.tsx new file mode 100644 index 0000000000..dbf37c2d5c --- /dev/null +++ b/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.test.tsx @@ -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(); + + for (const item of data) { + expect(screen.queryByText(item.resolved.name)).toBeInTheDocument(); + } + }); + + it('should display and invoke the next button', async () => { + const { rerender } = render( + , + ); + + expect( + screen.queryAllByRole('button', { name: 'Next Page' })[0], + ).toBeDisabled(); + + const fn = jest.fn(); + + rerender(); + + 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( + , + ); + + expect( + screen.queryAllByRole('button', { name: 'Next Page' })[0], + ).toBeDisabled(); + + const fn = jest.fn(); + + rerender(); + + const prevButton = screen.queryAllByRole('button', { + name: 'Previous Page', + })[0]; + expect(prevButton).toBeEnabled(); + + fireEvent.click(prevButton); + expect(fn).toHaveBeenCalled(); + }); +}); diff --git a/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx new file mode 100644 index 0000000000..771878b65c --- /dev/null +++ b/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx @@ -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; + +/** + * @internal + */ +export function PaginatedCatalogTable(props: PaginatedCatalogTableProps) { + const { columns, data, next, prev } = props; + + return ( + { + 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: '' } }} + /> + ); +} diff --git a/plugins/catalog/src/components/CatalogTable/columns.tsx b/plugins/catalog/src/components/CatalogTable/columns.tsx index 3c31fcadf1..4260a0c26b 100644 --- a/plugins/catalog/src/components/CatalogTable/columns.tsx +++ b/plugins/catalog/src/components/CatalogTable/columns.tsx @@ -100,11 +100,15 @@ export const columnFactories = Object.freeze({ ), }; }, - createSpecTypeColumn(): TableColumn { + createSpecTypeColumn( + options: { + hidden: boolean; + } = { hidden: false }, + ): TableColumn { return { title: 'Type', field: 'entity.spec.type', - hidden: true, + hidden: options.hidden, width: 'auto', }; },