From 2bdefc3243e9a035bbb8b676bcd1731245649eb1 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 10 Nov 2023 13:00:11 +0100 Subject: [PATCH 01/37] core-components: create TableLoadingBody Signed-off-by: Vincenzo Scamporlino --- .../src/components/Table/Table.tsx | 21 +-------- .../src/components/Table/TableLoadingBody.tsx | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 20 deletions(-) create mode 100644 packages/core-components/src/components/Table/TableLoadingBody.tsx diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index e4ee9b5995..a556cda7b2 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -53,7 +53,6 @@ import React, { import { SelectProps } from '../Select/Select'; import { Filter, Filters, SelectedFilters, Without } from './Filters'; -import CircularProgress from '@material-ui/core/CircularProgress'; // Material-table is not using the standard icons available in in material-ui. https://github.com/mbrn/material-table/issues/51 const tableIcons: Icons = { @@ -474,25 +473,7 @@ export function Table(props: TableProps) { const Body = useCallback( (bodyProps: any /* no type for this in material-table */) => { if (isLoading) { - return ( - - - - - - - - - - ); + return ; } if (emptyContent && hasNoRows) { 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 ( + + + + + + + + + + ); +} From 57937d93f885c52371f1797266c0dabf7e14a8c1 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 10 Nov 2023 13:03:07 +0100 Subject: [PATCH 02/37] core-components: export tableStyles Signed-off-by: Vincenzo Scamporlino --- packages/core-components/src/components/Table/Table.tsx | 4 ++-- packages/core-components/src/components/Table/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index a556cda7b2..4e9c3b5fb7 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -153,7 +153,7 @@ const useFilterStyles = makeStyles( export type TableClassKey = 'root'; -const useTableStyles = makeStyles( +export const tableStyles = makeStyles( () => ({ root: { display: 'flex', @@ -313,7 +313,7 @@ export function Table(props: TableProps) { isLoading: isLoading, ...restProps } = props; - const tableClasses = useTableStyles(); + const tableClasses = tableStyles(); const theme = useTheme(); diff --git a/packages/core-components/src/components/Table/index.ts b/packages/core-components/src/components/Table/index.ts index 00e1ae9de5..d1f2e9d342 100644 --- a/packages/core-components/src/components/Table/index.ts +++ b/packages/core-components/src/components/Table/index.ts @@ -17,7 +17,7 @@ export type { TableFiltersClassKey } from './Filters'; export { SubvalueCell } from './SubvalueCell'; export type { SubvalueCellClassKey } from './SubvalueCell'; -export { Table } from './Table'; +export { Table, tableStyles } from './Table'; export type { TableColumn, TableFilter, From ac800f49dde77e1e9b78dd9bab5c0c9d381520da Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 10 Nov 2023 13:27:41 +0100 Subject: [PATCH 03/37] core-components: create BaseTable without filters Signed-off-by: Vincenzo Scamporlino --- .../src/components/Table/Table.tsx | 131 +++++++++++------- .../src/components/Table/index.ts | 2 +- 2 files changed, 81 insertions(+), 52 deletions(-) diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index 4e9c3b5fb7..bcda04bab5 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -53,6 +53,7 @@ import React, { import { SelectProps } from '../Select/Select'; import { Filter, Filters, SelectedFilters, Without } from './Filters'; +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 = { @@ -315,8 +316,6 @@ export function Table(props: TableProps) { } = props; const tableClasses = tableStyles(); - const theme = useTheme(); - const calculatedInitialState = { ...defaultInitialState, ...initialState }; const [filtersOpen, setFiltersOpen] = useState( @@ -332,8 +331,6 @@ export function Table(props: TableProps) { calculatedInitialState.filters, ); - const MTColumns = convertColumns(columns, theme); - const [search, setSearch] = useState(calculatedInitialState.search); useEffect(() => { @@ -351,12 +348,6 @@ 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, @@ -468,11 +459,59 @@ export function Table(props: TableProps) { [toggleFilters, hasFilters, selectedFiltersLength, setSearch], ); + return ( + + {filtersOpen && data && typeof data !== 'function' && filters?.length && ( + + )} + + components={{ + Toolbar, + ...components, + }} + options={options} + columns={columns} + title={title} + subtitle={subtitle} + data={typeof data === 'function' ? data : tableData} + {...restProps} + /> + + ); +} + +Table.icons = Object.freeze(tableIcons); + +export function BaseTable( + props: TableProps & { + loading?: boolean; + emptyContent?: ReactNode; + subtitle?: string; + }, +) { + const { + columns, + components, + data, + emptyContent, + loading, + options, + title, + subtitle, + localization, + ...restProps + } = props; + 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) { + if (loading) { return ; } @@ -488,49 +527,39 @@ export function Table(props: TableProps) { return ; }, - [hasNoRows, emptyContent, columnCount, isLoading], + [hasNoRows, emptyContent, columnCount, loading], ); + const theme = useTheme(); return ( - - {filtersOpen && data && typeof data !== 'function' && filters?.length && ( - - )} - - components={{ - Header: StyledMTableHeader, - Toolbar, - Body, - ...components, - }} - options={{ ...defaultOptions, ...options }} - columns={MTColumns} - icons={tableIcons} - title={ - <> - - {title} + + components={{ + Header: StyledMTableHeader, + Body, + ...components, + }} + options={{ headerStyle: { textTransform: 'uppercase' }, ...options }} + columns={convertColumns(columns, theme)} + icons={tableIcons} + title={ + <> + + {title} + + {subtitle && ( + + {subtitle} - {subtitle && ( - - {subtitle} - - )} - - } - data={typeof data === 'function' ? data : tableData} - style={{ width: '100%' }} - localization={{ - toolbar: { searchPlaceholder: 'Filter', searchTooltip: 'Filter' }, - }} - {...restProps} - /> - + )} + + } + data={data} + style={{ width: '100%' }} + localization={{ + ...localization, + toolbar: { searchPlaceholder: 'Filter', searchTooltip: 'Filter' }, + }} + {...restProps} + /> ); } - -Table.icons = Object.freeze(tableIcons); diff --git a/packages/core-components/src/components/Table/index.ts b/packages/core-components/src/components/Table/index.ts index d1f2e9d342..2ddfb52ab3 100644 --- a/packages/core-components/src/components/Table/index.ts +++ b/packages/core-components/src/components/Table/index.ts @@ -17,7 +17,7 @@ export type { TableFiltersClassKey } from './Filters'; export { SubvalueCell } from './SubvalueCell'; export type { SubvalueCellClassKey } from './SubvalueCell'; -export { Table, tableStyles } from './Table'; +export { Table, BaseTable, tableStyles } from './Table'; export type { TableColumn, TableFilter, From 96fef11c9c67cd34458f23fd7fe2d82a70b3b8f8 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 10 Nov 2023 13:37:52 +0100 Subject: [PATCH 04/37] catalog-react: fetch entities in batches Signed-off-by: Vincenzo Scamporlino --- .../src/hooks/useEntityListProvider.tsx | 131 +++++++++++++----- 1 file changed, 96 insertions(+), 35 deletions(-) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index 948efc969f..6dba849ba8 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -44,7 +44,11 @@ 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'; /** @public */ @@ -110,16 +114,21 @@ export const EntityListContext = createContext< type OutputState = { appliedFilters: EntityFilters; + appliedCursor?: string; entities: Entity[]; backendEntities: Entity[]; }; +type EntityListProviderProps = PropsWithChildren<{ + enablePagination?: boolean; +}>; + /** * 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 +141,19 @@ 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 { queryParameters, cursor: initialCursor } = useMemo(() => { + const parsed = qs.parse(location.search, { + ignoreQueryPrefix: true, + }); + + return { + queryParameters: + parsed.filters ?? ({} as Record), + cursor: typeof parsed.cursor === 'string' ? parsed.cursor : undefined, + }; + }, [location]); + + const [cursor] = useState(initialCursor); const [outputState, setOutputState] = useState>( () => { @@ -156,11 +171,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 +185,70 @@ 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 (props.enablePagination) { + const limit = 2; + 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), + }); + } + } 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), + }); + } + } } 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 +256,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,7 +268,14 @@ export const EntityListProvider = ( window.history?.replaceState(null, document.title, newUrl); } }, - [catalogApi, queryParameters, requestedFilters, outputState], + [ + catalogApi, + queryParameters, + requestedFilters, + outputState, + cursor, + props.enablePagination, + ], { loading: true }, ); From acc928f0adc099b999cb68dd040161a508376618 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 10 Nov 2023 13:38:52 +0100 Subject: [PATCH 05/37] catalog-react: expose next and prev functions Signed-off-by: Vincenzo Scamporlino --- .../src/hooks/useEntityListProvider.tsx | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index 6dba849ba8..ef1673d69a 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -50,6 +50,7 @@ import { reduceEntityFilters, } from '../utils'; import { useApi } from '@backstage/core-plugin-api'; +import { QueryEntitiesResponse } from '@backstage/catalog-client'; /** @public */ export type DefaultEntityFilters = { @@ -95,6 +96,8 @@ export type EntityListContextProps< | ((prevFilters: EntityFilters) => Partial), ) => void; + next?: () => void; + prev?: () => void; /** * Filter values from query parameters. */ @@ -117,6 +120,7 @@ type OutputState = { appliedCursor?: string; entities: Entity[]; backendEntities: Entity[]; + pageInfo?: QueryEntitiesResponse['pageInfo']; }; type EntityListProviderProps = PropsWithChildren<{ @@ -141,6 +145,7 @@ 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, cursor: initialCursor } = useMemo(() => { const parsed = qs.parse(location.search, { ignoreQueryPrefix: true, @@ -153,7 +158,7 @@ export const EntityListProvider = ( }; }, [location]); - const [cursor] = useState(initialCursor); + const [cursor, setCursor] = useState(initialCursor); const [outputState, setOutputState] = useState>( () => { @@ -199,6 +204,7 @@ export const EntityListProvider = ( appliedCursor: cursor, backendEntities: response.items, entities: response.items.filter(entityFilter), + pageInfo: response.pageInfo, }); } } else { @@ -218,6 +224,7 @@ export const EntityListProvider = ( appliedFilters: requestedFilters, backendEntities: response.items, entities: response.items.filter(entityFilter), + pageInfo: response.pageInfo, }); } } @@ -281,7 +288,7 @@ export const EntityListProvider = ( // 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( ( @@ -298,6 +305,24 @@ export const EntityListProvider = ( [], ); + const next = useMemo(() => { + const newCursor = outputState.pageInfo?.nextCursor; + if (!newCursor || !props.enablePagination) { + return undefined; + } + + return () => setCursor(newCursor); + }, [outputState.pageInfo?.nextCursor, props.enablePagination]); + + const prev = useMemo(() => { + const newCursor = outputState.pageInfo?.prevCursor; + if (!newCursor || !props.enablePagination) { + return undefined; + } + + return () => setCursor(newCursor); + }, [outputState.pageInfo?.prevCursor, props.enablePagination]); + const value = useMemo( () => ({ filters: outputState.appliedFilters, @@ -307,8 +332,10 @@ export const EntityListProvider = ( queryParameters, loading, error, + next, + prev, }), - [outputState, updateFilters, queryParameters, loading, error], + [outputState, updateFilters, queryParameters, loading, error, next, prev], ); return ( From 6f478040633ea0022c42b89a798e7ea9512d51c3 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 10 Nov 2023 13:39:53 +0100 Subject: [PATCH 06/37] catalog-react: reset filters on cursor change Signed-off-by: Vincenzo Scamporlino --- plugins/catalog-react/src/hooks/useEntityListProvider.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index ef1673d69a..2e187789fd 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -296,6 +296,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; From 58b29d0488e363ac5d8f31487657a205dc2a252c Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 10 Nov 2023 13:41:54 +0100 Subject: [PATCH 07/37] catalog: add options to createSpecTypeColumn Signed-off-by: Vincenzo Scamporlino --- .../catalog/src/components/CatalogTable/columns.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/catalog/src/components/CatalogTable/columns.tsx b/plugins/catalog/src/components/CatalogTable/columns.tsx index 3c31fcadf1..37f68db4e5 100644 --- a/plugins/catalog/src/components/CatalogTable/columns.tsx +++ b/plugins/catalog/src/components/CatalogTable/columns.tsx @@ -100,11 +100,17 @@ export const columnFactories = Object.freeze({ ), }; }, - createSpecTypeColumn(): TableColumn { + createSpecTypeColumn( + { + hidden, + }: { + hidden: boolean; + } = { hidden: true }, + ): TableColumn { return { title: 'Type', field: 'entity.spec.type', - hidden: true, + hidden, width: 'auto', }; }, From 69a021b10e3af59b061a2d7edcb0c0cde9b7a032 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 10 Nov 2023 13:45:07 +0100 Subject: [PATCH 08/37] catalog: add PaginatedCatalogTable component Signed-off-by: Vincenzo Scamporlino --- .../CatalogTable/PaginatedCatalogTable.tsx | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx diff --git a/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx new file mode 100644 index 0000000000..e74dfca78b --- /dev/null +++ b/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx @@ -0,0 +1,59 @@ +/* + * 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 Box from '@material-ui/core/Box'; + +import { BaseTable, TableProps, tableStyles } from '@backstage/core-components'; +import { CatalogTableRow } from './types'; +import { useEntityList } from '@backstage/plugin-catalog-react'; + +export function PaginatedCatalogTable(props: TableProps) { + const { columns, data } = props; + const tableClasses = tableStyles(); + + const { next, prev } = useEntityList(); + + 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: '' } }} + /> + + + ); +} From a126dbb3fb2ee5ba3dd51eb69b1473fe8270944f Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 10 Nov 2023 13:48:24 +0100 Subject: [PATCH 09/37] catalog: wire up enablePagination prop Signed-off-by: Vincenzo Scamporlino --- .../components/CatalogPage/DefaultCatalogPage.tsx | 13 +++++++++++-- .../src/components/CatalogTable/CatalogTable.tsx | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx index a50cd4fa2a..b1cc0d3913 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx @@ -50,11 +50,16 @@ import { CatalogTableColumnsFunc } from '../CatalogTable/types'; export interface BaseCatalogPageProps { filters: ReactNode; content?: ReactNode; + enablePagination?: boolean; } /** @internal */ export function BaseCatalogPage(props: BaseCatalogPageProps) { - const { filters, content = } = props; + const { + filters, + enablePagination, + content = , + } = props; const orgName = useApi(configApiRef).getOptionalString('organization.name') ?? 'Backstage'; const createComponentLink = useRouteRef(createComponentRouteRef); @@ -70,7 +75,7 @@ export function BaseCatalogPage(props: BaseCatalogPageProps) { /> All your software catalog entities - + {filters} {content} @@ -94,6 +99,7 @@ export interface DefaultCatalogPageProps { tableOptions?: TableProps['options']; emptyContent?: ReactNode; ownerPickerMode?: EntityOwnerPickerProps['mode']; + enablePagination?: boolean; } export function DefaultCatalogPage(props: DefaultCatalogPageProps) { @@ -105,10 +111,12 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) { tableOptions = {}, emptyContent, ownerPickerMode, + enablePagination, } = props; return ( @@ -127,6 +135,7 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) { actions={actions} tableOptions={tableOptions} emptyContent={emptyContent} + enablePagination={enablePagination} /> } /> diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 4dd3fa257d..4b3cb4c276 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -58,6 +58,7 @@ export interface CatalogTableProps { tableOptions?: TableProps['options']; emptyContent?: ReactNode; subtitle?: string; + enablePagination?: boolean; } const YellowStar = withStyles({ From b421ac4f1192fb13c11505e4895829cca3c55a1a Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 10 Nov 2023 13:52:17 +0100 Subject: [PATCH 10/37] catalog: remove ugly hack to hide type column Signed-off-by: Vincenzo Scamporlino --- .../components/CatalogTable/CatalogTable.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 4b3cb4c276..7d5ee0192d 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -78,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 }), @@ -90,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) { @@ -101,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: @@ -134,7 +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'); @@ -240,10 +243,6 @@ export const CatalogTable = (props: CatalogTableProps) => { }; }); - 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 || ''; @@ -273,5 +272,3 @@ export const CatalogTable = (props: CatalogTableProps) => { /> ); }; - -CatalogTable.columns = columnFactories; From cfa69e7fc828cb3bd1a2bb2b33c2f78ac289f5fb Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 10 Nov 2023 13:53:36 +0100 Subject: [PATCH 11/37] catalog: move toEntityRow out Signed-off-by: Vincenzo Scamporlino --- .../components/CatalogTable/CatalogTable.tsx | 66 ++++++++++--------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 7d5ee0192d..60ad89eca7 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -211,37 +211,7 @@ 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 rows = entities.sort(refCompare).map(toEntityRow); const showPagination = rows.length > 20; const currentKind = filters.kind?.value || ''; @@ -272,3 +242,37 @@ 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, + }, + }; +} From 6115ddb8701e1d9075c69b798bcb2436d9dff465 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 10 Nov 2023 13:54:49 +0100 Subject: [PATCH 12/37] catalog: add PaginatedCatalogTable Signed-off-by: Vincenzo Scamporlino --- .../components/CatalogTable/CatalogTable.tsx | 67 ++++++++++++++++--- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 60ad89eca7..e495c993a9 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}. @@ -138,9 +139,6 @@ export const CatalogTable = (props: CatalogTableProps) => { [columns, entityListContext], ); - // TODO(timbonicus): remove the title from the CatalogTable once using EntitySearchBar - const titlePreamble = capitalize(filters.user?.value ?? 'all'); - if (error) { return (
@@ -211,19 +209,66 @@ export const CatalogTable = (props: CatalogTableProps) => { }, ]; - const rows = entities.sort(refCompare).map(toEntityRow); - - 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(' '); + if (props.enablePagination) { + return ( + + ); + } + + return ( + + ); +}; + +/** @internal */ +function LegacyCatalogTable(props: { + actions?: TableProps['actions']; + columns: TableColumn[]; + emptyContent: React.ReactNode; + entities: Entity[]; + loading: boolean; + options?: TableProps['options']; + subtitle?: string; + title: string; +}) { + const { + actions, + columns, + emptyContent, + entities, + loading, + subtitle, + options, + title, + } = props; + + const rows = entities.sort(refCompare).map(toEntityRow); + const showPagination = rows.length > 20; + return ( isLoading={loading} - columns={tableColumns} + columns={columns} options={{ paging: showPagination, pageSize: 20, @@ -232,16 +277,16 @@ export const CatalogTable = (props: CatalogTableProps) => { showEmptyDataSourceMessage: !loading, padding: 'dense', pageSizeOptions: [20, 50, 100], - ...tableOptions, + ...options, }} - title={`${titleDisplay} (${entities.length})`} + title={title} data={rows} - actions={actions || defaultActions} + actions={actions} subtitle={subtitle} emptyContent={emptyContent} /> ); -}; +} CatalogTable.columns = columnFactories; From 7a53ef518b1a37bae094f918178c10a751795624 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 10 Nov 2023 13:55:12 +0100 Subject: [PATCH 13/37] app: temporarily enable pagination Signed-off-by: Vincenzo Scamporlino --- packages/app/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index c2296d01e7..6e653802ef 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -182,7 +182,7 @@ const routes = ( }> {homePage} - } /> + } /> } From 513c05aae65ed91abd18163e0dd613245b4aa566 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Sun, 12 Nov 2023 23:25:29 +0100 Subject: [PATCH 14/37] catalog: enable paginated page through app-config Signed-off-by: Vincenzo Scamporlino --- packages/app/src/App.tsx | 2 +- .../components/CatalogPage/DefaultCatalogPage.tsx | 15 +++++---------- .../src/components/CatalogTable/CatalogTable.tsx | 6 +++--- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 6e653802ef..c2296d01e7 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -182,7 +182,7 @@ const routes = ( }> {homePage} - } /> + } /> } diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx index b1cc0d3913..4797871fbf 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx @@ -50,21 +50,20 @@ import { CatalogTableColumnsFunc } from '../CatalogTable/types'; export interface BaseCatalogPageProps { filters: ReactNode; content?: ReactNode; - enablePagination?: boolean; } /** @internal */ export function BaseCatalogPage(props: BaseCatalogPageProps) { - const { - filters, - enablePagination, - content = , - } = props; + const { filters, content = } = props; const orgName = useApi(configApiRef).getOptionalString('organization.name') ?? 'Backstage'; const createComponentLink = useRouteRef(createComponentRouteRef); const { t } = useTranslationRef(catalogTranslationRef); + const enablePagination = useApi(configApiRef).getOptionalBoolean( + 'catalog.experimental.paginatedEntities', + ); + return ( @@ -99,7 +98,6 @@ export interface DefaultCatalogPageProps { tableOptions?: TableProps['options']; emptyContent?: ReactNode; ownerPickerMode?: EntityOwnerPickerProps['mode']; - enablePagination?: boolean; } export function DefaultCatalogPage(props: DefaultCatalogPageProps) { @@ -111,12 +109,10 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) { tableOptions = {}, emptyContent, ownerPickerMode, - enablePagination, } = props; return ( @@ -135,7 +131,6 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) { actions={actions} tableOptions={tableOptions} emptyContent={emptyContent} - enablePagination={enablePagination} /> } /> diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index e495c993a9..c5eddce118 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -59,7 +59,6 @@ export interface CatalogTableProps { tableOptions?: TableProps['options']; emptyContent?: ReactNode; subtitle?: string; - enablePagination?: boolean; } const YellowStar = withStyles({ @@ -131,7 +130,8 @@ export const CatalogTable = (props: CatalogTableProps) => { } = 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( () => @@ -217,7 +217,7 @@ export const CatalogTable = (props: CatalogTableProps) => { .filter(s => s) .join(' '); - if (props.enablePagination) { + if (enablePagination) { return ( Date: Sun, 12 Nov 2023 23:26:59 +0100 Subject: [PATCH 15/37] catalog-react: expose pageInfo from context Signed-off-by: Vincenzo Scamporlino --- .../src/hooks/useEntityListProvider.tsx | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index 2e187789fd..fb05cd2fe7 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -96,8 +96,6 @@ export type EntityListContextProps< | ((prevFilters: EntityFilters) => Partial), ) => void; - next?: () => void; - prev?: () => void; /** * Filter values from query parameters. */ @@ -105,6 +103,11 @@ export type EntityListContextProps< loading: boolean; error?: Error; + + pageInfo?: { + next?: () => void; + prev?: () => void; + }; }; /** @@ -152,8 +155,10 @@ export const EntityListProvider = ( }); return { - queryParameters: - parsed.filters ?? ({} as Record), + queryParameters: (parsed.filters ?? {}) as Record< + string, + string | string[] + >, cursor: typeof parsed.cursor === 'string' ? parsed.cursor : undefined, }; }, [location]); @@ -166,6 +171,7 @@ export const EntityListProvider = ( appliedFilters: {} as EntityFilters, entities: [], backendEntities: [], + pageInfo: props.enablePagination ? {} : undefined, }; }, ); @@ -311,23 +317,18 @@ export const EntityListProvider = ( [], ); - const next = useMemo(() => { - const newCursor = outputState.pageInfo?.nextCursor; - if (!newCursor || !props.enablePagination) { + const pageInfo = useMemo(() => { + if (!props.enablePagination) { return undefined; } - return () => setCursor(newCursor); - }, [outputState.pageInfo?.nextCursor, props.enablePagination]); - - const prev = useMemo(() => { - const newCursor = outputState.pageInfo?.prevCursor; - if (!newCursor || !props.enablePagination) { - return undefined; - } - - return () => setCursor(newCursor); - }, [outputState.pageInfo?.prevCursor, props.enablePagination]); + const prevCursor = outputState.pageInfo?.prevCursor; + const nextCursor = outputState.pageInfo?.nextCursor; + return { + prev: prevCursor ? () => setCursor(prevCursor) : undefined, + next: nextCursor ? () => setCursor(nextCursor) : undefined, + }; + }, [props.enablePagination, outputState.pageInfo]); const value = useMemo( () => ({ @@ -338,10 +339,9 @@ export const EntityListProvider = ( queryParameters, loading, error, - next, - prev, + pageInfo, }), - [outputState, updateFilters, queryParameters, loading, error, next, prev], + [outputState, updateFilters, queryParameters, loading, error, pageInfo], ); return ( From 3c4cfed9ac8a6d81ae6f31cf417e9b71ab5ad3a7 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Sun, 12 Nov 2023 23:28:09 +0100 Subject: [PATCH 16/37] catalog: refactor pagination methods Signed-off-by: Vincenzo Scamporlino --- .../CatalogTable/PaginatedCatalogTable.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx index e74dfca78b..b592ec01d2 100644 --- a/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx @@ -19,14 +19,19 @@ import Box from '@material-ui/core/Box'; import { BaseTable, TableProps, tableStyles } from '@backstage/core-components'; import { CatalogTableRow } from './types'; -import { useEntityList } from '@backstage/plugin-catalog-react'; -export function PaginatedCatalogTable(props: TableProps) { - const { columns, data } = props; +type PaginatedCatalogTableProps = { + prev?(): void; + next?(): void; +} & TableProps; + +/** + * @internal + */ +export function PaginatedCatalogTable(props: PaginatedCatalogTableProps) { + const { columns, data, next, prev } = props; const tableClasses = tableStyles(); - const { next, prev } = useEntityList(); - return ( <> From 3410ab2decada83890ecbb8d0976e66e81d39409 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Sun, 12 Nov 2023 23:41:29 +0100 Subject: [PATCH 17/37] core-components: fix missing props Signed-off-by: Vincenzo Scamporlino --- packages/core-components/src/components/Table/Table.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index bcda04bab5..d5fa1e2da7 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -308,10 +308,9 @@ export function Table(props: TableProps) { subtitle, filters, initialState, - emptyContent, onStateChange, components, - isLoading: isLoading, + isLoading: loading, ...restProps } = props; const tableClasses = tableStyles(); @@ -477,6 +476,7 @@ export function Table(props: TableProps) { columns={columns} title={title} subtitle={subtitle} + loading={loading} data={typeof data === 'function' ? data : tableData} {...restProps} /> From 9e89eb7179f2aef7bb1bf61e3e35937697b3ecdf Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 13 Nov 2023 13:36:42 +0100 Subject: [PATCH 18/37] core-components: add missing tags Signed-off-by: Vincenzo Scamporlino --- packages/core-components/api-report.md | 15 ++++++++++++++- .../src/components/Table/Table.tsx | 8 ++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index 0361b81f0d..ed7fd0c765 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -13,6 +13,7 @@ import { BackstageUserIdentity } from '@backstage/core-plugin-api'; import { BottomNavigationActionProps } from '@material-ui/core/BottomNavigationAction'; import { ButtonProps as ButtonProps_2 } from '@material-ui/core/Button'; import { CardHeaderProps } from '@material-ui/core/CardHeader'; +import { ClassNameMap } from '@material-ui/styles'; import { Column } from '@material-table/core'; import { ComponentClass } from 'react'; import { ComponentProps } from 'react'; @@ -104,6 +105,15 @@ export type BackstageOverrides = Overrides & { >; }; +// @public (undocumented) +export function BaseTable( + props: TableProps & { + loading?: boolean; + emptyContent?: ReactNode; + subtitle?: string; + }, +): React_2.JSX.Element; + // @public (undocumented) export type BoldHeaderClassKey = 'root' | 'title' | 'subheader'; @@ -1438,6 +1448,9 @@ export type TableState = { filters?: SelectedFilters; }; +// @public +export const tableStyles: (props?: any) => ClassNameMap; + // Warning: (ae-missing-release-tag) "TableToolbarClassKey" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1538,6 +1551,6 @@ export type WarningPanelClassKey = // src/components/DependencyGraph/types.d.ts:22:9 - (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/core-components" does not have an export "DependencyNode" // src/components/DependencyGraph/types.d.ts:26:9 - (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/core-components" does not have an export "DependencyNode" // src/components/TabbedLayout/RoutedTabs.d.ts:9:5 - (ae-forgotten-export) The symbol "SubRoute_2" needs to be exported by the entry point index.d.ts -// src/components/Table/Table.d.ts:20:5 - (ae-forgotten-export) The symbol "SelectedFilters" needs to be exported by the entry point index.d.ts +// src/components/Table/Table.d.ts:26:5 - (ae-forgotten-export) The symbol "SelectedFilters" needs to be exported by the entry point index.d.ts // src/layout/ErrorBoundary/ErrorBoundary.d.ts:8:5 - (ae-forgotten-export) The symbol "SlackChannel" needs to be exported by the entry point index.d.ts ``` diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index d5fa1e2da7..09957e2e03 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -154,6 +154,11 @@ const useFilterStyles = makeStyles( export type TableClassKey = 'root'; +/** + * Style classes for the `Table` component. + * + * @public + */ export const tableStyles = makeStyles( () => ({ root: { @@ -486,6 +491,9 @@ export function Table(props: TableProps) { Table.icons = Object.freeze(tableIcons); +/** + * @public + */ export function BaseTable( props: TableProps & { loading?: boolean; From 3a46e39dfdcda94de9450ef782d549933ff94eb9 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 13 Nov 2023 13:39:14 +0100 Subject: [PATCH 19/37] catalog: api reports Signed-off-by: Vincenzo Scamporlino --- plugins/catalog-react/api-report.md | 11 ++++++++++- plugins/catalog-react/src/hooks/index.ts | 1 + .../catalog-react/src/hooks/useEntityListProvider.tsx | 5 ++++- plugins/catalog/api-report.md | 6 +++++- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index e84fa9815c..bbe51070c8 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -284,13 +284,22 @@ 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<{ + enablePagination?: boolean; +}>; + // @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.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index fb05cd2fe7..63066e0592 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -126,7 +126,10 @@ type OutputState = { pageInfo?: QueryEntitiesResponse['pageInfo']; }; -type EntityListProviderProps = PropsWithChildren<{ +/** + * @public + */ +export type EntityListProviderProps = PropsWithChildren<{ enablePagination?: boolean; }>; diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index e3df51deab..830a812083 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -158,7 +158,11 @@ export const CatalogTable: { createSystemColumn(): TableColumn; createOwnerColumn(): TableColumn; createSpecTargetsColumn(): TableColumn; - createSpecTypeColumn(): TableColumn; + createSpecTypeColumn({ + hidden, + }?: { + hidden: boolean; + }): TableColumn; createSpecLifecycleColumn(): TableColumn; createMetadataDescriptionColumn(): TableColumn; createTagsColumn(): TableColumn; From 70b5064a6ea0c7027d0f1485fd57442aea18705b Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 14 Nov 2023 09:53:59 +0100 Subject: [PATCH 20/37] catalog: make createSpecTypeColumn visible by default Signed-off-by: Vincenzo Scamporlino --- plugins/catalog/src/components/CatalogTable/columns.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog/src/components/CatalogTable/columns.tsx b/plugins/catalog/src/components/CatalogTable/columns.tsx index 37f68db4e5..3e6ce12a9c 100644 --- a/plugins/catalog/src/components/CatalogTable/columns.tsx +++ b/plugins/catalog/src/components/CatalogTable/columns.tsx @@ -105,7 +105,7 @@ export const columnFactories = Object.freeze({ hidden, }: { hidden: boolean; - } = { hidden: true }, + } = { hidden: false }, ): TableColumn { return { title: 'Type', From 6f13983167f63c71f79b48000b678e9dcc32b89f Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 14 Nov 2023 12:46:05 +0100 Subject: [PATCH 21/37] allow resources in uffizzi Signed-off-by: Vincenzo Scamporlino --- .github/uffizzi/uffizzi.production.app-config.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From 9e0c5208b8585bcf30eab355c6f4930c9bcc4c41 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 15 Nov 2023 13:39:59 +0100 Subject: [PATCH 22/37] catalog: add PaginatedCatalogTable tests Signed-off-by: Vincenzo Scamporlino --- .../PaginatedCatalogTable.test.tsx | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.test.tsx 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(); + }); +}); From 4194f507a2b7436fb869954eac0fabc517aa6d07 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 15 Nov 2023 13:42:04 +0100 Subject: [PATCH 23/37] catalog-react: refactor useEntityListProvider tests Signed-off-by: Vincenzo Scamporlino --- .../src/hooks/useEntityListProvider.test.tsx | 131 +++++++++++------- 1 file changed, 78 insertions(+), 53 deletions(-) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index edbfb65c0c..1a8d7b5c30 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; enablePagination: 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 enablePagination = 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({ enablePagination }), }); 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({ enablePagination }), 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}`, + enablePagination, + }), }); await waitFor(() => { @@ -186,7 +193,7 @@ describe('', () => { it('does not fetch when only frontend filters change', async () => { const { result } = renderHook(() => useEntityList(), { - wrapper, + wrapper: createWrapper({ enablePagination }), }); 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({ enablePagination }), }); 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({ enablePagination }), }); 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,15 @@ describe('', () => { expect(result.current.error).toBeDefined(); }); }); + + it('returns an empty pageInfo', async () => { + const { result } = renderHook(() => useEntityList(), { + wrapper: createWrapper({ enablePagination }), + }); + await waitFor(() => { + expect(mockCatalogApi.getEntities).toHaveBeenCalled(); + }); + + expect(result.current.pageInfo).toBeUndefined(); + }); }); From 7318ec36cc6a3f5294d966aa1606d887b3fd928f Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 15 Nov 2023 13:43:04 +0100 Subject: [PATCH 24/37] catalog-react: add tests for queryEntities method Signed-off-by: Vincenzo Scamporlino --- .../src/hooks/useEntityListProvider.test.tsx | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index 1a8d7b5c30..392e3832cd 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -271,3 +271,149 @@ describe('', () => { expect(result.current.pageInfo).toBeUndefined(); }); }); + +describe('', () => { + const origReplaceState = window.history.replaceState; + const enablePagination = true; + const limit = 2; + 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({ enablePagination }), + }); + + 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({ enablePagination }), + 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}`, + enablePagination, + }), + }); + + 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({ enablePagination }), + }); + + 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({ enablePagination }), + }); + + 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({ enablePagination }), + }); + + 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(); + }); + }); +}); From b9736f55fc88aa8e8b9092dfe3711f72a1affaee Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 15 Nov 2023 13:44:33 +0100 Subject: [PATCH 25/37] catalog-react: add cursor tests Signed-off-by: Vincenzo Scamporlino --- .../src/hooks/useEntityListProvider.test.tsx | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index 392e3832cd..c4a02bd122 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -416,4 +416,73 @@ describe('', () => { 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({ enablePagination }), + }); + 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({ enablePagination }), + }); + 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({ enablePagination }), + }); + 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, + }); + }); + }); + }); }); From 35bd8a89b6b3e78075a76b148a32b0557f50b2c6 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 15 Nov 2023 20:06:22 +0100 Subject: [PATCH 26/37] core-components: remove BaseTable refactoring Table logic Signed-off-by: Vincenzo Scamporlino --- packages/core-components/api-report.md | 15 +- .../src/components/Table/Table.tsx | 275 ++++++++---------- .../src/components/Table/index.ts | 2 +- .../CatalogTable/PaginatedCatalogTable.tsx | 54 ++-- 4 files changed, 148 insertions(+), 198 deletions(-) diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index ed7fd0c765..0361b81f0d 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -13,7 +13,6 @@ import { BackstageUserIdentity } from '@backstage/core-plugin-api'; import { BottomNavigationActionProps } from '@material-ui/core/BottomNavigationAction'; import { ButtonProps as ButtonProps_2 } from '@material-ui/core/Button'; import { CardHeaderProps } from '@material-ui/core/CardHeader'; -import { ClassNameMap } from '@material-ui/styles'; import { Column } from '@material-table/core'; import { ComponentClass } from 'react'; import { ComponentProps } from 'react'; @@ -105,15 +104,6 @@ export type BackstageOverrides = Overrides & { >; }; -// @public (undocumented) -export function BaseTable( - props: TableProps & { - loading?: boolean; - emptyContent?: ReactNode; - subtitle?: string; - }, -): React_2.JSX.Element; - // @public (undocumented) export type BoldHeaderClassKey = 'root' | 'title' | 'subheader'; @@ -1448,9 +1438,6 @@ export type TableState = { filters?: SelectedFilters; }; -// @public -export const tableStyles: (props?: any) => ClassNameMap; - // Warning: (ae-missing-release-tag) "TableToolbarClassKey" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1551,6 +1538,6 @@ export type WarningPanelClassKey = // src/components/DependencyGraph/types.d.ts:22:9 - (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/core-components" does not have an export "DependencyNode" // src/components/DependencyGraph/types.d.ts:26:9 - (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/core-components" does not have an export "DependencyNode" // src/components/TabbedLayout/RoutedTabs.d.ts:9:5 - (ae-forgotten-export) The symbol "SubRoute_2" needs to be exported by the entry point index.d.ts -// src/components/Table/Table.d.ts:26:5 - (ae-forgotten-export) The symbol "SelectedFilters" needs to be exported by the entry point index.d.ts +// src/components/Table/Table.d.ts:20:5 - (ae-forgotten-export) The symbol "SelectedFilters" needs to be exported by the entry point index.d.ts // src/layout/ErrorBoundary/ErrorBoundary.d.ts:8:5 - (ae-forgotten-export) The symbol "SlackChannel" needs to be exported by the entry point index.d.ts ``` diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index 09957e2e03..c49c953264 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -48,6 +48,7 @@ import React, { ReactNode, useCallback, useEffect, + useMemo, useState, } from 'react'; @@ -154,12 +155,7 @@ const useFilterStyles = makeStyles( export type TableClassKey = 'root'; -/** - * Style classes for the `Table` component. - * - * @public - */ -export const tableStyles = makeStyles( +const tableStyles = makeStyles( () => ({ root: { display: 'flex', @@ -308,9 +304,11 @@ export function Table(props: TableProps) { const { data, columns, + emptyContent, options, title, subtitle, + localization, filters, initialState, onStateChange, @@ -320,6 +318,8 @@ export function Table(props: TableProps) { } = props; const tableClasses = tableStyles(); + const theme = useTheme(); + const calculatedInitialState = { ...defaultInitialState, ...initialState }; const [filtersOpen, setFiltersOpen] = useState( @@ -329,8 +329,7 @@ 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, ); @@ -358,13 +357,9 @@ export function Table(props: TableProps) { [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); @@ -390,62 +385,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( @@ -463,26 +408,50 @@ export function Table(props: TableProps) { [toggleFilters, hasFilters, selectedFiltersLength, setSearch], ); + const hasNoRows = typeof data !== 'function' && data.length === 0; + const columnCount = columns.length; + const Body = useMemo( + () => makeBody({ hasNoRows, emptyContent, columnCount, loading }), + [hasNoRows, emptyContent, columnCount, loading], + ); + return ( {filtersOpen && data && typeof data !== 'function' && filters?.length && ( )} - + components={{ + Header: StyledMTableHeader, + Body, Toolbar, ...components, }} - options={options} - columns={columns} - title={title} - subtitle={subtitle} - loading={loading} - data={typeof data === 'function' ? data : tableData} + options={{ headerStyle: { textTransform: 'uppercase' }, ...options }} + columns={convertColumns(columns, theme)} + icons={tableIcons} + title={ + <> + + {title} + + {subtitle && ( + + {subtitle} + + )} + + } + data={tableData} + style={{ width: '100%' }} + localization={{ + toolbar: { searchPlaceholder: 'Filter', searchTooltip: 'Filter' }, + ...localization, + }} {...restProps} /> @@ -491,83 +460,83 @@ export function Table(props: TableProps) { Table.icons = Object.freeze(tableIcons); -/** - * @public - */ -export function BaseTable( - props: TableProps & { - loading?: boolean; - emptyContent?: ReactNode; - subtitle?: string; - }, -) { - const { - columns, - components, - data, - emptyContent, - loading, - options, - title, - subtitle, - localization, - ...restProps - } = props; +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 ; + } - const hasNoRows = typeof data !== 'function' && data.length === 0; + if (emptyContent && hasNoRows) { + return ( + + + {emptyContent} + + + ); + } - const columnCount = columns.length; - const Body = useCallback( - (bodyProps: any /* no type for this in material-table */) => { - if (loading) { - return ; - } - - if (emptyContent && hasNoRows) { - return ( - - - {emptyContent} - - - ); - } - - return ; - }, - [hasNoRows, emptyContent, columnCount, loading], - ); - const theme = useTheme(); - - return ( - - components={{ - Header: StyledMTableHeader, - Body, - ...components, - }} - options={{ headerStyle: { textTransform: 'uppercase' }, ...options }} - columns={convertColumns(columns, theme)} - icons={tableIcons} - title={ - <> - - {title} - - {subtitle && ( - - {subtitle} - - )} - - } - data={data} - style={{ width: '100%' }} - localization={{ - ...localization, - toolbar: { searchPlaceholder: 'Filter', searchTooltip: 'Filter' }, - }} - {...restProps} - /> - ); + 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/index.ts b/packages/core-components/src/components/Table/index.ts index 2ddfb52ab3..00e1ae9de5 100644 --- a/packages/core-components/src/components/Table/index.ts +++ b/packages/core-components/src/components/Table/index.ts @@ -17,7 +17,7 @@ export type { TableFiltersClassKey } from './Filters'; export { SubvalueCell } from './SubvalueCell'; export type { SubvalueCellClassKey } from './SubvalueCell'; -export { Table, BaseTable, tableStyles } from './Table'; +export { Table } from './Table'; export type { TableColumn, TableFilter, diff --git a/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx index b592ec01d2..771878b65c 100644 --- a/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/PaginatedCatalogTable.tsx @@ -15,9 +15,8 @@ */ import React from 'react'; -import Box from '@material-ui/core/Box'; -import { BaseTable, TableProps, tableStyles } from '@backstage/core-components'; +import { Table, TableProps } from '@backstage/core-components'; import { CatalogTableRow } from './types'; type PaginatedCatalogTableProps = { @@ -30,35 +29,30 @@ type PaginatedCatalogTableProps = { */ export function PaginatedCatalogTable(props: PaginatedCatalogTableProps) { const { columns, data, next, prev } = props; - const tableClasses = tableStyles(); 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: '' } }} - /> - - + { + 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: '' } }} + /> ); } From 91da1a35d99591cf08a377dd312a721c3f389750 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 15 Nov 2023 20:15:58 +0100 Subject: [PATCH 27/37] catalog: add missing props Signed-off-by: Vincenzo Scamporlino --- .../components/CatalogTable/CatalogTable.tsx | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index c5eddce118..beba77aee5 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -221,7 +221,15 @@ export const CatalogTable = (props: CatalogTableProps) => { return ( ); } @@ -230,11 +238,11 @@ export const CatalogTable = (props: CatalogTableProps) => { ); @@ -245,8 +253,8 @@ function LegacyCatalogTable(props: { actions?: TableProps['actions']; columns: TableColumn[]; emptyContent: React.ReactNode; - entities: Entity[]; - loading: boolean; + data: Entity[]; + isLoading: boolean; options?: TableProps['options']; subtitle?: string; title: string; @@ -255,26 +263,26 @@ function LegacyCatalogTable(props: { actions, columns, emptyContent, - entities, - loading, + data, + isLoading, subtitle, options, title, } = props; - const rows = entities.sort(refCompare).map(toEntityRow); + const rows = data.sort(refCompare).map(toEntityRow); const showPagination = rows.length > 20; return ( - isLoading={loading} + isLoading={isLoading} columns={columns} options={{ paging: showPagination, pageSize: 20, actionsColumnIndex: -1, loadingType: 'linear', - showEmptyDataSourceMessage: !loading, + showEmptyDataSourceMessage: !isLoading, padding: 'dense', pageSizeOptions: [20, 50, 100], ...options, From 608f7745fa14c384eb16bb3a6724bed8412068fd Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 15 Nov 2023 20:16:27 +0100 Subject: [PATCH 28/37] catalog: expose experimental pagination config Signed-off-by: Vincenzo Scamporlino --- plugins/catalog/config.d.ts | 25 +++++++++++++++++++++++++ plugins/catalog/package.json | 6 ++++-- 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 plugins/catalog/config.d.ts diff --git a/plugins/catalog/config.d.ts b/plugins/catalog/config.d.ts new file mode 100644 index 0000000000..a853d660c8 --- /dev/null +++ b/plugins/catalog/config.d.ts @@ -0,0 +1,25 @@ +/* + * 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?: { + experimental?: { + /** + * @visibility frontend + */ + paginatedEntities?: boolean; + }; + }; +} 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" } From 8587f067d2616df8f76c144211b90997a2f617b9 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 15 Nov 2023 20:16:46 +0100 Subject: [PATCH 29/37] chore: pagination changesets Signed-off-by: Vincenzo Scamporlino --- .changeset/breezy-pans-glow.md | 5 +++++ .changeset/silly-numbers-wash.md | 15 +++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 .changeset/breezy-pans-glow.md create mode 100644 .changeset/silly-numbers-wash.md 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/silly-numbers-wash.md b/.changeset/silly-numbers-wash.md new file mode 100644 index 0000000000..a4fd20ea8c --- /dev/null +++ b/.changeset/silly-numbers-wash.md @@ -0,0 +1,15 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Added experimental 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 configuration as follows: + +```diff + catalog: ++ experimental: ++ paginatedEntities: true +``` From d118a9c19f919b3a65560bc092ab39e6dd588d8d Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 15 Nov 2023 20:26:51 +0100 Subject: [PATCH 30/37] catalog: refactor CatalogTable Signed-off-by: Vincenzo Scamporlino --- .../components/CatalogTable/CatalogTable.tsx | 72 ++++++------------- 1 file changed, 21 insertions(+), 51 deletions(-) diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index beba77aee5..006210bb1d 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -123,7 +123,6 @@ const defaultColumnsFunc: CatalogTableColumnsFunc = ({ filters, entities }) => { export const CatalogTable = (props: CatalogTableProps) => { const { columns = defaultColumnsFunc, - actions, tableOptions, subtitle, emptyContent, @@ -217,16 +216,26 @@ export const CatalogTable = (props: CatalogTableProps) => { .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 ( { ); } - return ( - - ); -}; - -/** @internal */ -function LegacyCatalogTable(props: { - actions?: TableProps['actions']; - columns: TableColumn[]; - emptyContent: React.ReactNode; - data: Entity[]; - isLoading: boolean; - options?: TableProps['options']; - subtitle?: string; - title: string; -}) { - const { - actions, - columns, - emptyContent, - data, - isLoading, - subtitle, - options, - title, - } = props; - - const rows = data.sort(refCompare).map(toEntityRow); - const showPagination = rows.length > 20; + const rows = entities.sort(refCompare).map(toEntityRow); + const pageSize = 20; + const showPagination = rows.length > pageSize; return ( - isLoading={isLoading} - columns={columns} + isLoading={loading} + columns={tableColumns} options={{ paging: showPagination, - pageSize: 20, - actionsColumnIndex: -1, - loadingType: 'linear', - showEmptyDataSourceMessage: !isLoading, - padding: 'dense', + pageSize: pageSize, pageSizeOptions: [20, 50, 100], ...options, }} - title={title} + title={`${titleDisplay} (${entities.length})`} data={rows} actions={actions} subtitle={subtitle} emptyContent={emptyContent} /> ); -} +}; CatalogTable.columns = columnFactories; From 5c8a3e3960875fc15394be1fd0fb1abfb05133ad Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 15 Nov 2023 23:08:48 +0100 Subject: [PATCH 31/37] core-components: Table changeset Signed-off-by: Vincenzo Scamporlino --- .changeset/eleven-ants-pretend.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/eleven-ants-pretend.md 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. From a77572d076bafd9850624c542d0b3597b25deef3 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 16 Nov 2023 17:05:12 +0100 Subject: [PATCH 32/37] catalog: customize limit Signed-off-by: Vincenzo Scamporlino --- .changeset/silly-numbers-wash.md | 3 +-- plugins/catalog-react/api-report.md | 6 ++++- .../src/hooks/useEntityListProvider.test.tsx | 2 +- .../src/hooks/useEntityListProvider.tsx | 24 +++++++++++++------ plugins/catalog/config.d.ts | 14 ++++++----- .../CatalogPage/DefaultCatalogPage.tsx | 8 +++---- 6 files changed, 36 insertions(+), 21 deletions(-) diff --git a/.changeset/silly-numbers-wash.md b/.changeset/silly-numbers-wash.md index a4fd20ea8c..ba437006dd 100644 --- a/.changeset/silly-numbers-wash.md +++ b/.changeset/silly-numbers-wash.md @@ -10,6 +10,5 @@ To activate the pagination mode, simply update your configuration as follows: ```diff catalog: -+ experimental: -+ paginatedEntities: true ++ experimentalPagination: true ``` diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index bbe51070c8..9c1a6249ae 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -297,7 +297,11 @@ export const EntityListProvider: ( // @public (undocumented) export type EntityListProviderProps = PropsWithChildren<{ - enablePagination?: boolean; + enablePagination?: + | boolean + | { + limit?: number; + }; }>; // @public (undocumented) diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index c4a02bd122..5838c79cdd 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -275,7 +275,7 @@ describe('', () => { describe('', () => { const origReplaceState = window.history.replaceState; const enablePagination = true; - const limit = 2; + const limit = 20; const orderFields = [{ field: 'metadata.name', order: 'asc' }]; beforeEach(() => { diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index 63066e0592..9eaaab7da2 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -130,7 +130,7 @@ type OutputState = { * @public */ export type EntityListProviderProps = PropsWithChildren<{ - enablePagination?: boolean; + enablePagination?: boolean | { limit?: number }; }>; /** @@ -152,6 +152,17 @@ export const EntityListProvider = ( // update of the URL or two catalog sidebar links with different catalog filters. const location = useLocation(); + const enablePagination = + props.enablePagination === true || + typeof props.enablePagination === 'object'; + + const limit = + props.enablePagination && + typeof props.enablePagination === 'object' && + typeof props.enablePagination.limit === 'number' + ? props.enablePagination.limit + : 20; + const { queryParameters, cursor: initialCursor } = useMemo(() => { const parsed = qs.parse(location.search, { ignoreQueryPrefix: true, @@ -174,7 +185,7 @@ export const EntityListProvider = ( appliedFilters: {} as EntityFilters, entities: [], backendEntities: [], - pageInfo: props.enablePagination ? {} : undefined, + pageInfo: enablePagination ? {} : undefined, }; }, ); @@ -199,8 +210,7 @@ export const EntityListProvider = ( {} as Record, ); - if (props.enablePagination) { - const limit = 2; + if (enablePagination) { if (cursor) { if (cursor !== outputState.appliedCursor) { const entityFilter = reduceEntityFilters(compacted); @@ -290,7 +300,7 @@ export const EntityListProvider = ( requestedFilters, outputState, cursor, - props.enablePagination, + enablePagination, ], { loading: true }, ); @@ -321,7 +331,7 @@ export const EntityListProvider = ( ); const pageInfo = useMemo(() => { - if (!props.enablePagination) { + if (!enablePagination) { return undefined; } @@ -331,7 +341,7 @@ export const EntityListProvider = ( prev: prevCursor ? () => setCursor(prevCursor) : undefined, next: nextCursor ? () => setCursor(nextCursor) : undefined, }; - }, [props.enablePagination, outputState.pageInfo]); + }, [enablePagination, outputState.pageInfo]); const value = useMemo( () => ({ diff --git a/plugins/catalog/config.d.ts b/plugins/catalog/config.d.ts index a853d660c8..af749db905 100644 --- a/plugins/catalog/config.d.ts +++ b/plugins/catalog/config.d.ts @@ -15,11 +15,13 @@ */ export interface Config { catalog?: { - experimental?: { - /** - * @visibility frontend - */ - paginatedEntities?: boolean; - }; + /** + * @deepVisibility frontend + */ + experimentalPagination?: + | boolean + | { + limit?: number; + }; }; } diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx index 4797871fbf..68632e5708 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx @@ -60,9 +60,9 @@ export function BaseCatalogPage(props: BaseCatalogPageProps) { const createComponentLink = useRouteRef(createComponentRouteRef); const { t } = useTranslationRef(catalogTranslationRef); - const enablePagination = useApi(configApiRef).getOptionalBoolean( - 'catalog.experimental.paginatedEntities', - ); + const experimentalPagination = useApi(configApiRef).getOptional( + 'catalog.experimentalPagination', + ) as boolean | { limit: number } | undefined; return ( @@ -74,7 +74,7 @@ export function BaseCatalogPage(props: BaseCatalogPageProps) { /> All your software catalog entities - + {filters} {content} From 65dd6decfaa4c30fc1393c1945b9b062a5e34c97 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 21 Nov 2023 11:02:41 +0100 Subject: [PATCH 33/37] core-components: rollback namings Signed-off-by: Vincenzo Scamporlino --- packages/core-components/src/components/Table/Table.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index c49c953264..4b291a2d8c 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -155,7 +155,7 @@ const useFilterStyles = makeStyles( export type TableClassKey = 'root'; -const tableStyles = makeStyles( +const useTableStyles = makeStyles( () => ({ root: { display: 'flex', @@ -316,7 +316,7 @@ export function Table(props: TableProps) { isLoading: loading, ...restProps } = props; - const tableClasses = tableStyles(); + const tableClasses = useTableStyles(); const theme = useTheme(); From 9cc670cf680705e806641980ce34e5933d0ee390 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 21 Nov 2023 11:02:58 +0100 Subject: [PATCH 34/37] catalog: fix api reports Signed-off-by: Vincenzo Scamporlino --- plugins/catalog/src/components/CatalogTable/columns.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plugins/catalog/src/components/CatalogTable/columns.tsx b/plugins/catalog/src/components/CatalogTable/columns.tsx index 3e6ce12a9c..4260a0c26b 100644 --- a/plugins/catalog/src/components/CatalogTable/columns.tsx +++ b/plugins/catalog/src/components/CatalogTable/columns.tsx @@ -101,16 +101,14 @@ export const columnFactories = Object.freeze({ }; }, createSpecTypeColumn( - { - hidden, - }: { + options: { hidden: boolean; } = { hidden: false }, ): TableColumn { return { title: 'Type', field: 'entity.spec.type', - hidden, + hidden: options.hidden, width: 'auto', }; }, From 992671e2c8acbef458edab646da4372033cfa203 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 21 Nov 2023 12:13:03 +0100 Subject: [PATCH 35/37] catalog: toggle pagination through props Signed-off-by: Vincenzo Scamporlino --- .changeset/silly-numbers-wash.md | 14 ++++--- plugins/catalog-react/api-report.md | 13 +++--- plugins/catalog-react/src/hooks/index.ts | 1 + .../src/hooks/useEntityListProvider.test.tsx | 42 +++++++++---------- .../src/hooks/useEntityListProvider.tsx | 18 ++++---- plugins/catalog/api-report.md | 10 +++-- .../CatalogPage/DefaultCatalogPage.tsx | 17 ++++---- 7 files changed, 66 insertions(+), 49 deletions(-) diff --git a/.changeset/silly-numbers-wash.md b/.changeset/silly-numbers-wash.md index ba437006dd..c48b0dcc7a 100644 --- a/.changeset/silly-numbers-wash.md +++ b/.changeset/silly-numbers-wash.md @@ -2,13 +2,17 @@ '@backstage/plugin-catalog': patch --- -Added experimental pagination support to `CatalogIndexPage` +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. +`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 configuration as follows: +To activate the pagination mode, simply update your `App.tsx` as follows: ```diff - catalog: -+ experimentalPagination: true + const routes = ( + + ... +- } /> ++ } /> + ... ``` diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 9c1a6249ae..7e5e2fc5e7 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -297,11 +297,7 @@ export const EntityListProvider: ( // @public (undocumented) export type EntityListProviderProps = PropsWithChildren<{ - enablePagination?: - | boolean - | { - limit?: number; - }; + pagination?: Pagination; }>; // @public (undocumented) @@ -657,6 +653,13 @@ export class MockStarredEntitiesApi implements StarredEntitiesApi { toggleStarred(entityRef: string): Promise; } +// @public (undocumented) +export type Pagination = + | boolean + | { + limit?: number; + }; + // @public export interface StarredEntitiesApi { starredEntitie$(): Observable>; diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index c3021f8992..c2f9604de9 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -33,6 +33,7 @@ export type { DefaultEntityFilters, EntityListContextProps, EntityListProviderProps, + Pagination, } 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 5838c79cdd..f6881d4a80 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -87,7 +87,7 @@ const mockCatalogApi: Partial> = { }; const createWrapper = - (options: { location?: string; enablePagination: boolean }) => + (options: { location?: string; pagination: boolean }) => (props: PropsWithChildren) => { const InitialFiltersWrapper = ({ children }: PropsWithChildren) => { const { updateFilters } = useEntityList(); @@ -111,7 +111,7 @@ const createWrapper = [alertApiRef, { post: jest.fn() }], ]} > - + {props.children} @@ -121,7 +121,7 @@ const createWrapper = describe('', () => { const origReplaceState = window.history.replaceState; - const enablePagination = false; + const pagination = false; beforeEach(() => { window.history.replaceState = jest.fn(); @@ -136,7 +136,7 @@ describe('', () => { it('should send backend filters', async () => { const { result } = renderHook(() => useEntityList(), { - wrapper: createWrapper({ enablePagination }), + wrapper: createWrapper({ pagination }), }); await waitFor(() => { @@ -152,7 +152,7 @@ describe('', () => { it('resolves frontend filters', async () => { const { result } = renderHook(() => useEntityList(), { - wrapper: createWrapper({ enablePagination }), + wrapper: createWrapper({ pagination }), initialProps: { userFilter: 'all', }, @@ -178,7 +178,7 @@ describe('', () => { const { result } = renderHook(() => useEntityList(), { wrapper: createWrapper({ location: `/catalog?${query}`, - enablePagination, + pagination, }), }); @@ -193,7 +193,7 @@ describe('', () => { it('does not fetch when only frontend filters change', async () => { const { result } = renderHook(() => useEntityList(), { - wrapper: createWrapper({ enablePagination }), + wrapper: createWrapper({ pagination }), }); await waitFor(() => { @@ -220,7 +220,7 @@ describe('', () => { it('debounces multiple filter changes', async () => { const { result } = renderHook(() => useEntityList(), { - wrapper: createWrapper({ enablePagination }), + wrapper: createWrapper({ pagination }), }); await waitFor(() => { @@ -243,7 +243,7 @@ describe('', () => { it('returns an error on catalogApi failure', async () => { const { result } = renderHook(() => useEntityList(), { - wrapper: createWrapper({ enablePagination }), + wrapper: createWrapper({ pagination }), }); await waitFor(() => { @@ -262,7 +262,7 @@ describe('', () => { it('returns an empty pageInfo', async () => { const { result } = renderHook(() => useEntityList(), { - wrapper: createWrapper({ enablePagination }), + wrapper: createWrapper({ pagination }), }); await waitFor(() => { expect(mockCatalogApi.getEntities).toHaveBeenCalled(); @@ -272,9 +272,9 @@ describe('', () => { }); }); -describe('', () => { +describe('', () => { const origReplaceState = window.history.replaceState; - const enablePagination = true; + const pagination = true; const limit = 20; const orderFields = [{ field: 'metadata.name', order: 'asc' }]; @@ -291,7 +291,7 @@ describe('', () => { it('should send backend filters', async () => { const { result } = renderHook(() => useEntityList(), { - wrapper: createWrapper({ enablePagination }), + wrapper: createWrapper({ pagination }), }); await waitFor(() => { @@ -309,7 +309,7 @@ describe('', () => { it('resolves frontend filters', async () => { const { result } = renderHook(() => useEntityList(), { - wrapper: createWrapper({ enablePagination }), + wrapper: createWrapper({ pagination }), initialProps: { userFilter: 'all', }, @@ -335,7 +335,7 @@ describe('', () => { const { result } = renderHook(() => useEntityList(), { wrapper: createWrapper({ location: `/catalog?${query}`, - enablePagination, + pagination, }), }); @@ -350,7 +350,7 @@ describe('', () => { it('fetch when frontend filters change', async () => { const { result } = renderHook(() => useEntityList(), { - wrapper: createWrapper({ enablePagination }), + wrapper: createWrapper({ pagination }), }); await waitFor(() => { @@ -375,7 +375,7 @@ describe('', () => { it('debounces multiple filter changes', async () => { const { result } = renderHook(() => useEntityList(), { - wrapper: createWrapper({ enablePagination }), + wrapper: createWrapper({ pagination }), }); await waitFor(() => { @@ -400,7 +400,7 @@ describe('', () => { it('returns an error on catalogApi failure', async () => { const { result } = renderHook(() => useEntityList(), { - wrapper: createWrapper({ enablePagination }), + wrapper: createWrapper({ pagination }), }); await waitFor(() => { @@ -425,7 +425,7 @@ describe('', () => { totalItems: 10, }); const { result } = renderHook(() => useEntityList(), { - wrapper: createWrapper({ enablePagination }), + wrapper: createWrapper({ pagination }), }); await waitFor(() => { expect(mockCatalogApi.queryEntities).toHaveBeenCalled(); @@ -439,7 +439,7 @@ describe('', () => { it('returns pageInfo with next function and properly fetch next batch', async () => { const { result } = renderHook(() => useEntityList(), { - wrapper: createWrapper({ enablePagination }), + wrapper: createWrapper({ pagination }), }); await waitFor(() => { expect(mockCatalogApi.queryEntities).toHaveBeenCalled(); @@ -463,7 +463,7 @@ describe('', () => { it('returns pageInfo with prev function and properly fetch prev batch', async () => { const { result } = renderHook(() => useEntityList(), { - wrapper: createWrapper({ enablePagination }), + wrapper: createWrapper({ pagination }), }); await waitFor(() => { expect(mockCatalogApi.queryEntities).toHaveBeenCalled(); diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index 9eaaab7da2..4b7adfd26a 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -130,9 +130,14 @@ type OutputState = { * @public */ export type EntityListProviderProps = PropsWithChildren<{ - enablePagination?: boolean | { limit?: number }; + pagination?: Pagination; }>; +/** + * @public + */ +export type Pagination = boolean | { limit?: number }; + /** * Provides entities and filters for a catalog listing. * @public @@ -153,14 +158,13 @@ export const EntityListProvider = ( const location = useLocation(); const enablePagination = - props.enablePagination === true || - typeof props.enablePagination === 'object'; + props.pagination === true || typeof props.pagination === 'object'; const limit = - props.enablePagination && - typeof props.enablePagination === 'object' && - typeof props.enablePagination.limit === 'number' - ? props.enablePagination.limit + props.pagination && + typeof props.pagination === 'object' && + typeof props.pagination.limit === 'number' + ? props.pagination.limit : 20; const { queryParameters, cursor: initialCursor } = useMemo(() => { diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 830a812083..bef24b50d8 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -158,9 +158,7 @@ export const CatalogTable: { createSystemColumn(): TableColumn; createOwnerColumn(): TableColumn; createSpecTargetsColumn(): TableColumn; - createSpecTypeColumn({ - hidden, - }?: { + createSpecTypeColumn(options?: { hidden: boolean; }): TableColumn; createSpecLifecycleColumn(): TableColumn; @@ -238,6 +236,12 @@ export interface DefaultCatalogPageProps { // (undocumented) ownerPickerMode?: EntityOwnerPickerProps['mode']; // (undocumented) + pagination?: + | boolean + | { + limit?: number; + }; + // (undocumented) tableOptions?: TableProps['options']; } diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx index 68632e5708..21f8351127 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx @@ -37,6 +37,7 @@ import { EntityKindPicker, EntityNamespacePicker, EntityOwnerPickerProps, + Pagination, } from '@backstage/plugin-catalog-react'; import React, { ReactNode } from 'react'; import { createComponentRouteRef } from '../../routes'; @@ -47,23 +48,20 @@ 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?: Pagination; +}; /** @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); const { t } = useTranslationRef(catalogTranslationRef); - const experimentalPagination = useApi(configApiRef).getOptional( - 'catalog.experimentalPagination', - ) as boolean | { limit: number } | undefined; - return ( @@ -74,7 +72,7 @@ export function BaseCatalogPage(props: BaseCatalogPageProps) { /> All your software catalog entities - + {filters} {content} @@ -98,6 +96,7 @@ export interface DefaultCatalogPageProps { tableOptions?: TableProps['options']; emptyContent?: ReactNode; ownerPickerMode?: EntityOwnerPickerProps['mode']; + pagination?: boolean | { limit?: number }; } export function DefaultCatalogPage(props: DefaultCatalogPageProps) { @@ -108,6 +107,7 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) { initialKind = 'component', tableOptions = {}, emptyContent, + pagination, ownerPickerMode, } = props; @@ -133,6 +133,7 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) { emptyContent={emptyContent} /> } + pagination={pagination} /> ); } From a2c24865a2c0b95b486e4fee575b465f8515bb2e Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 21 Nov 2023 13:26:27 +0100 Subject: [PATCH 36/37] improve changesets Signed-off-by: Vincenzo Scamporlino --- .changeset/silly-numbers-wash.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/silly-numbers-wash.md b/.changeset/silly-numbers-wash.md index c48b0dcc7a..25df8b12c1 100644 --- a/.changeset/silly-numbers-wash.md +++ b/.changeset/silly-numbers-wash.md @@ -16,3 +16,5 @@ To activate the pagination mode, simply update your `App.tsx` as follows: + } /> ... ``` + +In case you have a custom catalog page and you want to enable pagination, you need to pass the `pagination` prop to `EntityListProvider` instead. From 901cc8886fe35ec968c16da0a486465c2cae26cd Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 21 Nov 2023 13:35:45 +0100 Subject: [PATCH 37/37] catalog: remove Pagination type Signed-off-by: Vincenzo Scamporlino --- plugins/catalog-react/api-report.md | 13 +++++-------- plugins/catalog-react/src/hooks/index.ts | 1 - .../src/hooks/useEntityListProvider.tsx | 7 +------ .../components/CatalogPage/DefaultCatalogPage.tsx | 3 +-- 4 files changed, 7 insertions(+), 17 deletions(-) diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 7e5e2fc5e7..8c6b3a729f 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -297,7 +297,11 @@ export const EntityListProvider: ( // @public (undocumented) export type EntityListProviderProps = PropsWithChildren<{ - pagination?: Pagination; + pagination?: + | boolean + | { + limit?: number; + }; }>; // @public (undocumented) @@ -653,13 +657,6 @@ export class MockStarredEntitiesApi implements StarredEntitiesApi { toggleStarred(entityRef: string): Promise; } -// @public (undocumented) -export type Pagination = - | boolean - | { - limit?: number; - }; - // @public export interface StarredEntitiesApi { starredEntitie$(): Observable>; diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index c2f9604de9..c3021f8992 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -33,7 +33,6 @@ export type { DefaultEntityFilters, EntityListContextProps, EntityListProviderProps, - Pagination, } from './useEntityListProvider'; export { useEntityTypeFilter } from './useEntityTypeFilter'; export { useRelatedEntities } from './useRelatedEntities'; diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index 4b7adfd26a..1ce87c262e 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -130,14 +130,9 @@ type OutputState = { * @public */ export type EntityListProviderProps = PropsWithChildren<{ - pagination?: Pagination; + pagination?: boolean | { limit?: number }; }>; -/** - * @public - */ -export type Pagination = boolean | { limit?: number }; - /** * Provides entities and filters for a catalog listing. * @public diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx index 21f8351127..1f724d10fc 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx @@ -37,7 +37,6 @@ import { EntityKindPicker, EntityNamespacePicker, EntityOwnerPickerProps, - Pagination, } from '@backstage/plugin-catalog-react'; import React, { ReactNode } from 'react'; import { createComponentRouteRef } from '../../routes'; @@ -51,7 +50,7 @@ import { CatalogTableColumnsFunc } from '../CatalogTable/types'; export type BaseCatalogPageProps = { filters: ReactNode; content?: ReactNode; - pagination?: Pagination; + pagination?: boolean | { limit?: number }; }; /** @internal */