New useTable() hook

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-08-02 11:51:35 +01:00
parent 98c0959729
commit e4388b5d19
8 changed files with 409 additions and 93 deletions
+47 -3
View File
@@ -1581,19 +1581,19 @@ export function TablePagination(props: TablePaginationProps): JSX_2.Element;
export interface TablePaginationProps
extends React.HTMLAttributes<HTMLDivElement> {
// (undocumented)
offset?: number;
// (undocumented)
onNextPage?: () => void;
// (undocumented)
onPageSizeChange?: (pageSize: number) => void;
// (undocumented)
onPreviousPage?: () => void;
// (undocumented)
pageIndex?: number;
// (undocumented)
pageSize?: number;
// (undocumented)
rowCount?: number;
// (undocumented)
setPageIndex?: (pageIndex: number) => void;
setOffset?: (offset: number) => void;
// (undocumented)
setPageSize?: (pageSize: number) => void;
// (undocumented)
@@ -1727,6 +1727,50 @@ export const useBreakpoint: () => {
// @public (undocumented)
export const useIcons: () => IconContextProps;
// @public
export function useTable<T = any>(
config?: UseTableConfig<T>,
): UseTableResult<T>;
// @public (undocumented)
export interface UseTableConfig<T = any> {
data?: T[];
pagination?: UseTablePaginationConfig;
}
// @public (undocumented)
export interface UseTablePagination<T = any> {
data?: T[];
nextPage: () => void;
offset: number;
pageSize: number;
paginationProps: TablePaginationProps;
previousPage: () => void;
setOffset: (offset: number) => void;
setPageSize: (pageSize: number) => void;
}
// @public (undocumented)
export interface UseTablePaginationConfig {
defaultOffset?: number;
defaultPageSize?: number;
offset?: number;
onNextPage?: () => void;
onOffsetChange?: (offset: number) => void;
onPageSizeChange?: (pageSize: number) => void;
onPreviousPage?: () => void;
pageSize?: number;
rowCount?: number;
showPageSizeOptions?: boolean;
}
// @public (undocumented)
export interface UseTableResult<T = any> {
data?: T[];
pagination: UseTablePagination<T>;
paginationProps: TablePaginationProps;
}
// @public (undocumented)
export interface UtilityProps extends SpaceProps {
// (undocumented)
@@ -24,6 +24,7 @@ import {
Row,
Cell,
CellProfile as CellProfileBUI,
useTable,
} from '.';
import { MemoryRouter } from 'react-router-dom';
import { data as data1 } from './mocked-data1';
@@ -80,15 +81,9 @@ export const TableOnly: Story = {
},
};
export const WithPagination: Story = {
export const WithPaginationUncontrolled: Story = {
render: () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const newData = data1.slice(
pageIndex * pageSize,
(pageIndex + 1) * pageSize,
);
const { data, paginationProps } = useTable({ data: data1 });
return (
<>
@@ -100,7 +95,7 @@ export const WithPagination: Story = {
<Column>Lifecycle</Column>
</TableHeader>
<TableBody>
{newData.map(item => (
{data?.map(item => (
<Row key={item.name}>
<Cell
title={item.name}
@@ -114,27 +109,28 @@ export const WithPagination: Story = {
))}
</TableBody>
</Table>
<TablePagination
pageIndex={pageIndex}
pageSize={pageSize}
rowCount={data1.length}
setPageIndex={setPageIndex}
setPageSize={setPageSize}
/>
<TablePagination {...paginationProps} />
</>
);
},
};
export const TableRockBand: Story = {
export const WithPaginationControlled: Story = {
render: () => {
const [pageIndex, setPageIndex] = useState(0);
const [offset, setOffset] = useState(0);
const [pageSize, setPageSize] = useState(5);
const newData = data4.slice(
pageIndex * pageSize,
(pageIndex + 1) * pageSize,
);
const { data, paginationProps } = useTable({
data: data4,
pagination: {
offset,
pageSize,
onOffsetChange: setOffset,
onPageSizeChange: setPageSize,
onNextPage: () => console.log('Next page analytics'),
onPreviousPage: () => console.log('Previous page analytics'),
},
});
return (
<>
@@ -146,7 +142,7 @@ export const TableRockBand: Story = {
<Column>Albums</Column>
</TableHeader>
<TableBody>
{newData.map(item => (
{data?.map(item => (
<Row key={item.name}>
<CellProfileBUI
name={item.name}
@@ -160,27 +156,23 @@ export const TableRockBand: Story = {
))}
</TableBody>
</Table>
<TablePagination
pageIndex={pageIndex}
pageSize={pageSize}
rowCount={data4.length}
setPageIndex={setPageIndex}
setPageSize={setPageSize}
/>
<TablePagination {...paginationProps} />
<div style={{ marginTop: '16px', fontSize: '12px', color: '#666' }}>
Current state: offset={offset}, pageSize={pageSize}
</div>
</>
);
},
};
export const RowClick: Story = {
export const TableRockBand: Story = {
render: () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const newData = data4.slice(
pageIndex * pageSize,
(pageIndex + 1) * pageSize,
);
const { data, paginationProps } = useTable({
data: data4,
pagination: {
defaultPageSize: 5,
},
});
return (
<>
@@ -192,7 +184,46 @@ export const RowClick: Story = {
<Column>Albums</Column>
</TableHeader>
<TableBody>
{newData.map(item => (
{data?.map(item => (
<Row key={item.name}>
<CellProfileBUI
name={item.name}
src={item.image}
href={item.website}
/>
<Cell title={item.genre} />
<Cell title={item.yearFormed.toString()} />
<Cell title={item.albums.toString()} />
</Row>
))}
</TableBody>
</Table>
<TablePagination {...paginationProps} />
</>
);
},
};
export const RowClick: Story = {
render: () => {
const { data, paginationProps } = useTable({
data: data4,
pagination: {
defaultPageSize: 5,
},
});
return (
<>
<Table>
<TableHeader>
<Column isRowHeader>Band name</Column>
<Column>Genre</Column>
<Column>Year formed</Column>
<Column>Albums</Column>
</TableHeader>
<TableBody>
{data?.map(item => (
<Row key={item.name} onAction={() => alert('Row clicked')}>
<CellProfileBUI
name={item.name}
@@ -206,13 +237,7 @@ export const RowClick: Story = {
))}
</TableBody>
</Table>
<TablePagination
pageIndex={pageIndex}
pageSize={pageSize}
rowCount={data4.length}
setPageIndex={setPageIndex}
setPageSize={setPageSize}
/>
<TablePagination {...paginationProps} />
</>
);
},
@@ -220,13 +245,12 @@ export const RowClick: Story = {
export const RowLink: Story = {
render: () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const newData = data4.slice(
pageIndex * pageSize,
(pageIndex + 1) * pageSize,
);
const { data, paginationProps } = useTable({
data: data4,
pagination: {
defaultPageSize: 5,
},
});
return (
<>
@@ -238,7 +262,7 @@ export const RowLink: Story = {
<Column>Albums</Column>
</TableHeader>
<TableBody>
{newData.map(item => (
{data?.map(item => (
<Row key={item.name} href="/band">
<CellProfileBUI
name={item.name}
@@ -252,13 +276,7 @@ export const RowLink: Story = {
))}
</TableBody>
</Table>
<TablePagination
pageIndex={pageIndex}
pageSize={pageSize}
rowCount={data4.length}
setPageIndex={setPageIndex}
setPageSize={setPageSize}
/>
<TablePagination {...paginationProps} />
</>
);
},
@@ -0,0 +1,87 @@
/*
* 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 { TablePaginationProps } from '../../TablePagination/types';
/** @public */
export interface UseTablePaginationConfig {
/** Total number of rows in the dataset - only needed when data is not provided at the top level */
rowCount?: number;
// Controlled pagination with offset/pageSize (Backstage style)
/** Current offset. When provided, pagination is controlled */
offset?: number;
/** Current page size. When provided, pagination is controlled */
pageSize?: number;
/** Callback when offset changes */
onOffsetChange?: (offset: number) => void;
/** Callback when page size changes */
onPageSizeChange?: (pageSize: number) => void;
// Uncontrolled pagination defaults
/** Default page size for uncontrolled mode */
defaultPageSize?: number;
/** Default offset for uncontrolled mode */
defaultOffset?: number;
// Analytics callbacks
/** Callback when next page is clicked */
onNextPage?: () => void;
/** Callback when previous page is clicked */
onPreviousPage?: () => void;
// UI options
/** Whether to show page size options */
showPageSizeOptions?: boolean;
}
/** @public */
export interface UseTablePagination<T = any> {
/** Props to pass to TablePagination component */
paginationProps: TablePaginationProps;
/** Current offset */
offset: number;
/** Current page size */
pageSize: number;
/** Sliced data for current page - only available when data is provided to useTable */
data?: T[];
/** Go to next page */
nextPage: () => void;
/** Go to previous page */
previousPage: () => void;
/** Set specific offset */
setOffset: (offset: number) => void;
/** Set page size */
setPageSize: (pageSize: number) => void;
}
/** @public */
export interface UseTableConfig<T = any> {
/** Full dataset - when provided, rowCount is calculated automatically and sliced data is returned */
data?: T[];
/** Pagination configuration */
pagination?: UseTablePaginationConfig;
}
/** @public */
export interface UseTableResult<T = any> {
/** Sliced data for current page */
data?: T[];
/** Props to pass to TablePagination component */
paginationProps: TablePaginationProps;
/** Pagination utilities */
pagination: UseTablePagination<T>;
}
@@ -0,0 +1,166 @@
/*
* 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 { useState, useMemo, useCallback } from 'react';
import type { TablePaginationProps } from '../../TablePagination/types';
import type {
UseTableConfig,
UseTableResult,
UseTablePagination,
} from './types';
/**
* Hook for managing table state including pagination and future features like sorting.
* Supports both controlled and uncontrolled modes using offset/pageSize pattern (Backstage style).
*
* @public
*/
export function useTable<T = any>(
config: UseTableConfig<T> = {},
): UseTableResult<T> {
const { data, pagination: paginationConfig = {} } = config;
const {
rowCount: providedRowCount,
offset: controlledOffset,
pageSize: controlledPageSize,
onOffsetChange,
onPageSizeChange,
defaultPageSize = 10,
defaultOffset = 0,
onNextPage,
onPreviousPage,
showPageSizeOptions = true,
} = paginationConfig;
// Determine if we're in controlled mode
const isControlled =
controlledOffset !== undefined || controlledPageSize !== undefined;
// Calculate row count from data or use provided value
const rowCount = data?.length ?? providedRowCount ?? 0;
// Internal state for uncontrolled mode
const [internalOffset, setInternalOffset] = useState(defaultOffset);
const [internalPageSize, setInternalPageSize] = useState(defaultPageSize);
// Calculate current values
const currentOffset = controlledOffset ?? internalOffset;
const currentPageSize = controlledPageSize ?? internalPageSize;
// Calculate sliced data if data array is provided
const currentData = useMemo(() => {
if (!data) return undefined;
return data.slice(currentOffset, currentOffset + currentPageSize);
}, [data, currentOffset, currentPageSize]);
// Update functions
const setOffset = useCallback(
(newOffset: number) => {
if (isControlled) {
onOffsetChange?.(newOffset);
} else {
setInternalOffset(newOffset);
}
},
[isControlled, onOffsetChange],
);
const setPageSize = useCallback(
(newPageSize: number) => {
// When changing page size, reset to first page to avoid showing empty results
const newOffset = 0;
if (isControlled) {
onPageSizeChange?.(newPageSize);
onOffsetChange?.(newOffset);
} else {
setInternalPageSize(newPageSize);
setInternalOffset(newOffset);
}
},
[isControlled, onPageSizeChange, onOffsetChange],
);
const nextPage = useCallback(() => {
const nextOffset = currentOffset + currentPageSize;
if (nextOffset < rowCount) {
onNextPage?.();
setOffset(nextOffset);
}
}, [currentOffset, currentPageSize, rowCount, onNextPage, setOffset]);
const previousPage = useCallback(() => {
if (currentOffset > 0) {
onPreviousPage?.();
const prevOffset = Math.max(0, currentOffset - currentPageSize);
setOffset(prevOffset);
}
}, [currentOffset, currentPageSize, onPreviousPage, setOffset]);
// Pagination props for TablePagination component
const paginationProps: TablePaginationProps = useMemo(
() => ({
offset: currentOffset,
pageSize: currentPageSize,
rowCount,
setOffset,
setPageSize,
onNextPage,
onPreviousPage,
showPageSizeOptions,
}),
[
currentOffset,
currentPageSize,
rowCount,
setOffset,
setPageSize,
onNextPage,
onPreviousPage,
showPageSizeOptions,
],
);
const pagination: UseTablePagination<T> = useMemo(
() => ({
paginationProps,
offset: currentOffset,
pageSize: currentPageSize,
data: currentData,
nextPage,
previousPage,
setOffset,
setPageSize,
}),
[
paginationProps,
currentOffset,
currentPageSize,
currentData,
nextPage,
previousPage,
setOffset,
setPageSize,
],
);
return {
data: currentData,
paginationProps,
pagination,
};
}
@@ -21,5 +21,12 @@ export { Column } from './components/Column';
export { Row } from './components/Row';
export { Cell } from './components/Cell';
export { CellProfile } from './components/CellProfile';
export { useTable } from './hooks/useTable';
export type { CellProps, CellProfileProps } from './types';
export type {
UseTableConfig,
UseTableResult,
UseTablePagination,
UseTablePaginationConfig,
} from './hooks/types';
@@ -22,11 +22,11 @@ const meta = {
title: 'Components/TablePagination',
component: TablePagination,
argTypes: {
pageIndex: { control: 'number' },
offset: { control: 'number' },
pageSize: { control: 'radio', options: [5, 10, 20, 30, 40, 50] },
rowCount: { control: 'number' },
showPageSizeOptions: { control: 'boolean', defaultValue: true },
setPageIndex: { action: 'setPageIndex' },
setOffset: { action: 'setOffset' },
setPageSize: { action: 'setPageSize' },
},
} satisfies Meta<typeof TablePagination>;
@@ -36,7 +36,7 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
pageIndex: 0,
offset: 0,
pageSize: 10,
rowCount: 100,
},
@@ -46,8 +46,8 @@ export const Default: Story = {
return (
<TablePagination
{...args}
setPageIndex={value => {
updateArgs({ pageIndex: value });
setOffset={value => {
updateArgs({ offset: value });
}}
setPageSize={value => {
updateArgs({ pageSize: value });
@@ -26,45 +26,41 @@ import type { TablePaginationProps } from './types';
export function TablePagination(props: TablePaginationProps) {
const {
className,
pageIndex,
offset,
pageSize,
rowCount,
onNextPage,
onPreviousPage,
onPageSizeChange,
setPageIndex,
setOffset,
setPageSize,
showPageSizeOptions = true,
...rest
} = props;
const fromCount = (pageIndex ?? 0) * (pageSize ?? 10) + 1;
const toCount = Math.min(
((pageIndex ?? 0) + 1) * (pageSize ?? 10),
rowCount ?? 0,
);
const currentOffset = offset ?? 0;
const currentPageSize = pageSize ?? 10;
const fromCount = currentOffset + 1;
const toCount = Math.min(currentOffset + currentPageSize, rowCount ?? 0);
const nextPage = () => {
const currentPageIndex = pageIndex ?? 0;
const currentPageSize = pageSize ?? 10;
const totalRows = rowCount ?? 0;
const nextOffset = currentOffset + currentPageSize;
// Check if there are more pages to navigate to
const maxPageIndex = Math.ceil(totalRows / currentPageSize) - 1;
if (currentPageIndex < maxPageIndex) {
// Check if there are more items to navigate to
if (nextOffset < totalRows) {
onNextPage?.(); // Analytics tracking
setPageIndex?.(currentPageIndex + 1); // Navigate to next page
setOffset?.(nextOffset); // Navigate to next page
}
};
const previousPage = () => {
const currentPageIndex = pageIndex ?? 0;
// Check if we can go to previous page
if (currentPageIndex > 0) {
if (currentOffset > 0) {
onPreviousPage?.(); // Analytics tracking
setPageIndex?.(currentPageIndex - 1); // Navigate to previous page
const prevOffset = Math.max(0, currentOffset - currentPageSize);
setOffset?.(prevOffset); // Navigate to previous page
}
};
@@ -103,7 +99,7 @@ export function TablePagination(props: TablePaginationProps) {
variant="secondary"
size="small"
onClick={previousPage}
isDisabled={pageIndex === 0}
isDisabled={currentOffset === 0}
icon={<Icon name="chevron-left" />}
aria-label="Previous"
/>
@@ -112,10 +108,8 @@ export function TablePagination(props: TablePaginationProps) {
size="small"
onClick={nextPage}
isDisabled={
pageIndex !== undefined &&
pageSize !== undefined &&
rowCount !== undefined &&
pageIndex >= Math.ceil(rowCount / pageSize) - 1
currentOffset + currentPageSize >= rowCount
}
icon={<Icon name="chevron-right" />}
aria-label="Next"
@@ -17,10 +17,10 @@
/** @public */
export interface TablePaginationProps
extends React.HTMLAttributes<HTMLDivElement> {
pageIndex?: number;
offset?: number;
pageSize?: number;
setPageSize?: (pageSize: number) => void;
setPageIndex?: (pageIndex: number) => void;
setOffset?: (offset: number) => void;
rowCount?: number;
onNextPage?: () => void;
onPreviousPage?: () => void;