feat(ui): expose pagination props in Table and useTable
Add support for custom pagination options in the Table component and useTable hook: - Add `pageSizeOptions` prop to customize page size dropdown options - Add `onPageSizeChange`, `onNextPage`, and `onPreviousPage` callbacks - Validate pageSize against options, warn and use first option if invalid - Export `PageSizeOption` type for custom option labels - Add visual story demonstrating custom page size options Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -196,6 +196,7 @@ export function Table<T extends TableItem>({
|
||||
{pagination.type === 'page' && (
|
||||
<TablePagination
|
||||
pageSize={pagination.pageSize}
|
||||
pageSizeOptions={pagination.pageSizeOptions}
|
||||
offset={pagination.offset}
|
||||
totalCount={pagination.totalCount}
|
||||
hasNextPage={pagination.hasNextPage}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2025 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 type { PageSizeOption } from '../../TablePagination/types';
|
||||
import type { PaginationOptions } from './types';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
function getOptionValue(option: number | PageSizeOption): number {
|
||||
return typeof option === 'number' ? option : option.value;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function getEffectivePageSize(
|
||||
paginationOptions: PaginationOptions,
|
||||
): number {
|
||||
const { pageSize, pageSizeOptions } = paginationOptions;
|
||||
|
||||
if (!pageSizeOptions) {
|
||||
return pageSize ?? DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
|
||||
const firstValue = getOptionValue(pageSizeOptions[0]);
|
||||
|
||||
if (pageSize === undefined) {
|
||||
return firstValue;
|
||||
}
|
||||
|
||||
const isValid = pageSizeOptions.some(opt => getOptionValue(opt) === pageSize);
|
||||
|
||||
if (isValid) {
|
||||
return pageSize;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`useTable: pageSize ${pageSize} is not in pageSizeOptions, using ${firstValue} instead`,
|
||||
);
|
||||
return firstValue;
|
||||
}
|
||||
@@ -45,11 +45,20 @@ export interface QueryOptions<TFilter> {
|
||||
}
|
||||
|
||||
/** @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 */
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
UseTableCompleteOptions,
|
||||
} from './types';
|
||||
import { useStableCallback } from './useStableCallback';
|
||||
import { getEffectivePageSize } from './getEffectivePageSize';
|
||||
|
||||
/** @internal */
|
||||
export function useCompletePagination<T extends TableItem, TFilter>(
|
||||
@@ -35,8 +36,8 @@ export function useCompletePagination<T extends TableItem, TFilter>(
|
||||
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;
|
||||
|
||||
@@ -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<T extends TableItem, TFilter>(
|
||||
options: UseTableCursorOptions<T, TFilter>,
|
||||
query: QueryState<TFilter>,
|
||||
): PaginationResult<T> & { 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;
|
||||
|
||||
@@ -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<T extends TableItem, TFilter>(
|
||||
options: UseTableOffsetOptions<T, TFilter>,
|
||||
query: QueryState<TFilter>,
|
||||
): PaginationResult<T> & { 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;
|
||||
|
||||
@@ -34,7 +34,14 @@ function useTableProps<T extends TableItem>(
|
||||
TableProps<T>,
|
||||
'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<T extends TableItem>(
|
||||
() => ({
|
||||
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<T extends TableItem>(
|
||||
paginationResult.onNextPage,
|
||||
paginationResult.onPreviousPage,
|
||||
paginationResult.onPageSizeChange,
|
||||
onNextPageCallback,
|
||||
onPreviousPageCallback,
|
||||
onPageSizeChangeCallback,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -338,3 +338,51 @@ export const StaleState: Story = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomPageSizeOptions: Story = {
|
||||
render: () => {
|
||||
const columns: ColumnConfig<Data1Item>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Name',
|
||||
isRowHeader: true,
|
||||
cell: item => <CellText title={item.name} />,
|
||||
},
|
||||
{
|
||||
id: 'owner',
|
||||
label: 'Owner',
|
||||
cell: item => <CellText title={item.owner.name} />,
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
label: 'Type',
|
||||
cell: item => <CellText title={item.type} />,
|
||||
},
|
||||
];
|
||||
|
||||
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 <Table columnConfig={columns} {...tableProps} />;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
*/
|
||||
|
||||
export { TablePagination } from './TablePagination';
|
||||
export type { TablePaginationProps } from './types';
|
||||
export type { TablePaginationProps, PageSizeOption } from './types';
|
||||
export { TablePaginationDefinition } from './definition';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user