diff --git a/.changeset/proud-sides-tan.md b/.changeset/proud-sides-tan.md new file mode 100644 index 0000000000..0790d0f72e --- /dev/null +++ b/.changeset/proud-sides-tan.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Added support for custom pagination options in `useTable` hook and `Table` component. You can now configure `pageSizeOptions` to customize the page size dropdown, and hook into pagination events via `onPageSizeChange`, `onNextPage`, and `onPreviousPage` callbacks. When `pageSize` doesn't match any option, the first option is used and a warning is logged. + +Affected components: Table, TablePagination diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 8c56f040c2..295d5e8d4f 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1195,15 +1195,29 @@ export interface PagePagination extends TablePaginationProps { } // @public (undocumented) -export interface PaginationOptions { +export interface PageSizeOption { // (undocumented) - getLabel?: TablePaginationProps['getLabel']; + label: string; + // (undocumented) + value: number; +} + +// @public (undocumented) +export interface PaginationOptions + extends Partial< + Pick< + TablePaginationProps, + | 'pageSize' + | 'pageSizeOptions' + | 'onPageSizeChange' + | 'onNextPage' + | 'onPreviousPage' + | 'showPageSizeOptions' + | 'getLabel' + > + > { // (undocumented) initialOffset?: number; - // (undocumented) - pageSize?: number; - // (undocumented) - showPageSizeOptions?: boolean; } // @public @@ -1559,6 +1573,7 @@ export interface TableItem { // @public export function TablePagination({ pageSize, + pageSizeOptions, offset, totalCount, hasNextPage, @@ -1603,6 +1618,8 @@ export interface TablePaginationProps { // (undocumented) pageSize: number; // (undocumented) + pageSizeOptions?: number[] | PageSizeOption[]; + // (undocumented) showPageSizeOptions?: boolean; // (undocumented) totalCount?: number; diff --git a/packages/ui/src/components/Table/components/Table.tsx b/packages/ui/src/components/Table/components/Table.tsx index 59020dc2b0..84211901a6 100644 --- a/packages/ui/src/components/Table/components/Table.tsx +++ b/packages/ui/src/components/Table/components/Table.tsx @@ -196,6 +196,7 @@ export function Table({ {pagination.type === 'page' && ( getOptionValue(opt) === pageSize); + + if (isValid) { + return pageSize; + } + + console.warn( + `useTable: pageSize ${pageSize} is not in pageSizeOptions, using ${firstValue} instead`, + ); + return firstValue; +} diff --git a/packages/ui/src/components/Table/hooks/types.ts b/packages/ui/src/components/Table/hooks/types.ts index a09fae0946..f22efd4b53 100644 --- a/packages/ui/src/components/Table/hooks/types.ts +++ b/packages/ui/src/components/Table/hooks/types.ts @@ -45,11 +45,20 @@ export interface QueryOptions { } /** @public */ -export interface PaginationOptions { - pageSize?: number; +export interface PaginationOptions + extends Partial< + Pick< + TablePaginationProps, + | 'pageSize' + | 'pageSizeOptions' + | 'onPageSizeChange' + | 'onNextPage' + | 'onPreviousPage' + | 'showPageSizeOptions' + | 'getLabel' + > + > { initialOffset?: number; - showPageSizeOptions?: boolean; - getLabel?: TablePaginationProps['getLabel']; } /** @public */ diff --git a/packages/ui/src/components/Table/hooks/useCompletePagination.ts b/packages/ui/src/components/Table/hooks/useCompletePagination.ts index 3f199335d5..70db68dae1 100644 --- a/packages/ui/src/components/Table/hooks/useCompletePagination.ts +++ b/packages/ui/src/components/Table/hooks/useCompletePagination.ts @@ -22,6 +22,7 @@ import type { UseTableCompleteOptions, } from './types'; import { useStableCallback } from './useStableCallback'; +import { getEffectivePageSize } from './getEffectivePageSize'; /** @internal */ export function useCompletePagination( @@ -35,8 +36,8 @@ export function useCompletePagination( filterFn, searchFn, } = options; - const { pageSize: defaultPageSize = 20, initialOffset = 0 } = - paginationOptions; + const { initialOffset = 0 } = paginationOptions; + const defaultPageSize = getEffectivePageSize(paginationOptions); const getData = useStableCallback(getDataProp); const { sort, filter, search } = query; diff --git a/packages/ui/src/components/Table/hooks/useCursorPagination.ts b/packages/ui/src/components/Table/hooks/useCursorPagination.ts index 007f39023f..5da56b3ce4 100644 --- a/packages/ui/src/components/Table/hooks/useCursorPagination.ts +++ b/packages/ui/src/components/Table/hooks/useCursorPagination.ts @@ -24,13 +24,14 @@ import type { import { usePageCache } from './usePageCache'; import { useStableCallback } from './useStableCallback'; import { useDebouncedReload } from './useDebouncedReload'; +import { getEffectivePageSize } from './getEffectivePageSize'; export function useCursorPagination( options: UseTableCursorOptions, query: QueryState, ): PaginationResult & { reload: () => void } { const { getData: getDataProp, paginationOptions = {} } = options; - const { pageSize: defaultPageSize = 20 } = paginationOptions; + const defaultPageSize = getEffectivePageSize(paginationOptions); const getData = useStableCallback(getDataProp); const { sort, filter, search } = query; diff --git a/packages/ui/src/components/Table/hooks/useOffsetPagination.ts b/packages/ui/src/components/Table/hooks/useOffsetPagination.ts index c5844ec018..b34dbe8a6c 100644 --- a/packages/ui/src/components/Table/hooks/useOffsetPagination.ts +++ b/packages/ui/src/components/Table/hooks/useOffsetPagination.ts @@ -24,14 +24,15 @@ import type { import { usePageCache } from './usePageCache'; import { useStableCallback } from './useStableCallback'; import { useDebouncedReload } from './useDebouncedReload'; +import { getEffectivePageSize } from './getEffectivePageSize'; export function useOffsetPagination( options: UseTableOffsetOptions, query: QueryState, ): PaginationResult & { reload: () => void } { const { getData: getDataProp, paginationOptions = {} } = options; - const { pageSize: defaultPageSize = 20, initialOffset = 0 } = - paginationOptions; + const { initialOffset = 0 } = paginationOptions; + const defaultPageSize = getEffectivePageSize(paginationOptions); const getData = useStableCallback(getDataProp); const { sort, filter, search } = query; diff --git a/packages/ui/src/components/Table/hooks/useTable.ts b/packages/ui/src/components/Table/hooks/useTable.ts index 6b653c41db..b8f5b8bd13 100644 --- a/packages/ui/src/components/Table/hooks/useTable.ts +++ b/packages/ui/src/components/Table/hooks/useTable.ts @@ -34,7 +34,14 @@ function useTableProps( TableProps, 'columnConfig' | 'rowConfig' | 'selection' | 'emptyState' > { - const { showPageSizeOptions = true, getLabel } = paginationOptions; + const { + showPageSizeOptions = true, + pageSizeOptions, + onPageSizeChange: onPageSizeChangeCallback, + onNextPage: onNextPageCallback, + onPreviousPage: onPreviousPageCallback, + getLabel, + } = paginationOptions; const previousDataRef = useRef(paginationResult.data); if (paginationResult.data) { @@ -48,18 +55,29 @@ function useTableProps( () => ({ type: 'page' as const, pageSize: paginationResult.pageSize, + pageSizeOptions, offset: paginationResult.offset, totalCount: paginationResult.totalCount, hasNextPage: paginationResult.hasNextPage, hasPreviousPage: paginationResult.hasPreviousPage, - onNextPage: paginationResult.onNextPage, - onPreviousPage: paginationResult.onPreviousPage, - onPageSizeChange: paginationResult.onPageSizeChange, + onNextPage: () => { + paginationResult.onNextPage(); + onNextPageCallback?.(); + }, + onPreviousPage: () => { + paginationResult.onPreviousPage(); + onPreviousPageCallback?.(); + }, + onPageSizeChange: (size: number) => { + paginationResult.onPageSizeChange(size); + onPageSizeChangeCallback?.(size); + }, showPageSizeOptions, getLabel, }), [ paginationResult.pageSize, + pageSizeOptions, paginationResult.offset, paginationResult.totalCount, paginationResult.hasNextPage, @@ -67,6 +85,9 @@ function useTableProps( paginationResult.onNextPage, paginationResult.onPreviousPage, paginationResult.onPageSizeChange, + onNextPageCallback, + onPreviousPageCallback, + onPageSizeChangeCallback, ], ); diff --git a/packages/ui/src/components/Table/stories/Table.visual.stories.tsx b/packages/ui/src/components/Table/stories/Table.visual.stories.tsx index 34259dde96..3ec6af5789 100644 --- a/packages/ui/src/components/Table/stories/Table.visual.stories.tsx +++ b/packages/ui/src/components/Table/stories/Table.visual.stories.tsx @@ -338,3 +338,51 @@ export const StaleState: Story = { ); }, }; + +export const CustomPageSizeOptions: Story = { + render: () => { + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => , + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + { + id: 'type', + label: 'Type', + cell: item => , + }, + ]; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => data1, + paginationOptions: { + pageSize: 3, + pageSizeOptions: [ + { label: '2 per page', value: 2 }, + { label: '3 per page', value: 3 }, + { label: '5 per page', value: 5 }, + { label: '7 per page', value: 7 }, + ], + onPageSizeChange: size => { + console.log('Page size changed to:', size); + }, + onNextPage: () => { + console.log('Navigated to next page'); + }, + onPreviousPage: () => { + console.log('Navigated to previous page'); + }, + }, + }); + + return ; + }, +}; diff --git a/packages/ui/src/components/TablePagination/TablePagination.tsx b/packages/ui/src/components/TablePagination/TablePagination.tsx index de9234afa5..632fb3188a 100644 --- a/packages/ui/src/components/TablePagination/TablePagination.tsx +++ b/packages/ui/src/components/TablePagination/TablePagination.tsx @@ -16,12 +16,43 @@ import clsx from 'clsx'; import { Text, ButtonIcon, Select } from '../..'; -import type { TablePaginationProps } from './types'; +import type { TablePaginationProps, PageSizeOption } from './types'; import { useStyles } from '../../hooks/useStyles'; import { TablePaginationDefinition } from './definition'; import styles from './TablePagination.module.css'; import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'; -import { useId } from 'react'; +import { useId, useMemo } from 'react'; + +const DEFAULT_PAGE_SIZE_OPTIONS: PageSizeOption[] = [ + { label: 'Show 5 results', value: 5 }, + { label: 'Show 10 results', value: 10 }, + { label: 'Show 20 results', value: 20 }, + { label: 'Show 30 results', value: 30 }, + { label: 'Show 40 results', value: 40 }, + { label: 'Show 50 results', value: 50 }, +]; + +function getOptionValue(option: number | PageSizeOption): number { + return typeof option === 'number' ? option : option.value; +} + +function isNumberArray( + options: number[] | PageSizeOption[], +): options is number[] { + return options.length > 0 && typeof options[0] === 'number'; +} + +function normalizePageSizeOptions( + options: number[] | PageSizeOption[], +): PageSizeOption[] { + if (isNumberArray(options)) { + return options.map(value => ({ + label: `Show ${value} results`, + value, + })); + } + return options; +} /** * Pagination controls for Table components with page navigation and size selection. @@ -30,6 +61,7 @@ import { useId } from 'react'; */ export function TablePagination({ pageSize, + pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, offset, totalCount, hasNextPage, @@ -42,15 +74,33 @@ export function TablePagination({ }: TablePaginationProps) { const { classNames } = useStyles(TablePaginationDefinition, {}); const labelId = useId(); + const normalizedOptions = useMemo( + () => normalizePageSizeOptions(pageSizeOptions), + [pageSizeOptions], + ); + + const effectivePageSize = useMemo(() => { + const isValid = pageSizeOptions.some( + opt => getOptionValue(opt) === pageSize, + ); + if (isValid) { + return pageSize; + } + const firstValue = getOptionValue(pageSizeOptions[0]); + console.warn( + `TablePagination: pageSize ${pageSize} is not in pageSizeOptions, using ${firstValue} instead`, + ); + return firstValue; + }, [pageSize, pageSizeOptions]); const hasItems = totalCount !== undefined && totalCount !== 0; let label = `${totalCount} items`; if (getLabel) { - label = getLabel({ pageSize, offset, totalCount }); + label = getLabel({ pageSize: effectivePageSize, offset, totalCount }); } else if (offset !== undefined) { const fromCount = offset + 1; - const toCount = Math.min(offset + pageSize, totalCount ?? 0); + const toCount = Math.min(offset + effectivePageSize, totalCount ?? 0); label = `${fromCount} - ${toCount} of ${totalCount}`; } @@ -62,16 +112,11 @@ export function TablePagination({ name="pageSize" size="small" aria-label="Select table page size" - placeholder="Show 10 results" - options={[ - { label: 'Show 5 results', value: '5' }, - { label: 'Show 10 results', value: '10' }, - { label: 'Show 20 results', value: '20' }, - { label: 'Show 30 results', value: '30' }, - { label: 'Show 40 results', value: '40' }, - { label: 'Show 50 results', value: '50' }, - ]} - defaultValue={pageSize.toString()} + options={normalizedOptions.map(opt => ({ + label: opt.label, + value: String(opt.value), + }))} + defaultValue={effectivePageSize.toString()} onChange={value => { const newPageSize = Number(value); onPageSizeChange?.(newPageSize); diff --git a/packages/ui/src/components/TablePagination/index.ts b/packages/ui/src/components/TablePagination/index.ts index 42642fb162..e0cd949abf 100644 --- a/packages/ui/src/components/TablePagination/index.ts +++ b/packages/ui/src/components/TablePagination/index.ts @@ -15,5 +15,5 @@ */ export { TablePagination } from './TablePagination'; -export type { TablePaginationProps } from './types'; +export type { TablePaginationProps, PageSizeOption } from './types'; export { TablePaginationDefinition } from './definition'; diff --git a/packages/ui/src/components/TablePagination/types.ts b/packages/ui/src/components/TablePagination/types.ts index 8bd370500a..04881a0f5f 100644 --- a/packages/ui/src/components/TablePagination/types.ts +++ b/packages/ui/src/components/TablePagination/types.ts @@ -14,9 +14,16 @@ * limitations under the License. */ +/** @public */ +export interface PageSizeOption { + label: string; + value: number; +} + /** @public */ export interface TablePaginationProps { pageSize: number; + pageSizeOptions?: number[] | PageSizeOption[]; offset?: number; totalCount?: number; hasNextPage: boolean;