From e4388b5d1996e3f7be3db8d53eecf5c505a86c75 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sat, 2 Aug 2025 11:51:35 +0100 Subject: [PATCH] New useTable() hook Signed-off-by: Charles de Dreuille --- packages/ui/report.api.md | 50 +++++- .../ui/src/components/Table/Table.stories.tsx | 140 ++++++++------- .../ui/src/components/Table/hooks/types.ts | 87 +++++++++ .../ui/src/components/Table/hooks/useTable.ts | 166 ++++++++++++++++++ packages/ui/src/components/Table/index.ts | 7 + .../TablePagination.stories.tsx | 10 +- .../TablePagination/TablePagination.tsx | 38 ++-- .../src/components/TablePagination/types.ts | 4 +- 8 files changed, 409 insertions(+), 93 deletions(-) create mode 100644 packages/ui/src/components/Table/hooks/types.ts create mode 100644 packages/ui/src/components/Table/hooks/useTable.ts diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 4979f42cd9..b9559df418 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1581,19 +1581,19 @@ export function TablePagination(props: TablePaginationProps): JSX_2.Element; export interface TablePaginationProps extends React.HTMLAttributes { // (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( + config?: UseTableConfig, +): UseTableResult; + +// @public (undocumented) +export interface UseTableConfig { + data?: T[]; + pagination?: UseTablePaginationConfig; +} + +// @public (undocumented) +export interface UseTablePagination { + 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 { + data?: T[]; + pagination: UseTablePagination; + paginationProps: TablePaginationProps; +} + // @public (undocumented) export interface UtilityProps extends SpaceProps { // (undocumented) diff --git a/packages/ui/src/components/Table/Table.stories.tsx b/packages/ui/src/components/Table/Table.stories.tsx index c9aa3ec14b..c71196efe1 100644 --- a/packages/ui/src/components/Table/Table.stories.tsx +++ b/packages/ui/src/components/Table/Table.stories.tsx @@ -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 = { Lifecycle - {newData.map(item => ( + {data?.map(item => ( - + ); }, }; -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 = { Albums - {newData.map(item => ( + {data?.map(item => ( - + +
+ Current state: offset={offset}, pageSize={pageSize} +
); }, }; -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 = { Albums - {newData.map(item => ( + {data?.map(item => ( + + + + + + + ))} + + + + + ); + }, +}; + +export const RowClick: Story = { + render: () => { + const { data, paginationProps } = useTable({ + data: data4, + pagination: { + defaultPageSize: 5, + }, + }); + + return ( + <> + + + Band name + Genre + Year formed + Albums + + + {data?.map(item => ( alert('Row clicked')}>
- + ); }, @@ -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 = { Albums - {newData.map(item => ( + {data?.map(item => ( - + ); }, diff --git a/packages/ui/src/components/Table/hooks/types.ts b/packages/ui/src/components/Table/hooks/types.ts new file mode 100644 index 0000000000..8c93d678fc --- /dev/null +++ b/packages/ui/src/components/Table/hooks/types.ts @@ -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 { + /** 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 { + /** Full dataset - when provided, rowCount is calculated automatically and sliced data is returned */ + data?: T[]; + /** Pagination configuration */ + pagination?: UseTablePaginationConfig; +} + +/** @public */ +export interface UseTableResult { + /** Sliced data for current page */ + data?: T[]; + /** Props to pass to TablePagination component */ + paginationProps: TablePaginationProps; + /** Pagination utilities */ + pagination: UseTablePagination; +} diff --git a/packages/ui/src/components/Table/hooks/useTable.ts b/packages/ui/src/components/Table/hooks/useTable.ts new file mode 100644 index 0000000000..2759fcde41 --- /dev/null +++ b/packages/ui/src/components/Table/hooks/useTable.ts @@ -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( + config: UseTableConfig = {}, +): UseTableResult { + 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 = 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, + }; +} diff --git a/packages/ui/src/components/Table/index.ts b/packages/ui/src/components/Table/index.ts index 97561b10ff..945621b26c 100644 --- a/packages/ui/src/components/Table/index.ts +++ b/packages/ui/src/components/Table/index.ts @@ -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'; diff --git a/packages/ui/src/components/TablePagination/TablePagination.stories.tsx b/packages/ui/src/components/TablePagination/TablePagination.stories.tsx index b0e841ec15..3a80302b70 100644 --- a/packages/ui/src/components/TablePagination/TablePagination.stories.tsx +++ b/packages/ui/src/components/TablePagination/TablePagination.stories.tsx @@ -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; @@ -36,7 +36,7 @@ type Story = StoryObj; export const Default: Story = { args: { - pageIndex: 0, + offset: 0, pageSize: 10, rowCount: 100, }, @@ -46,8 +46,8 @@ export const Default: Story = { return ( { - updateArgs({ pageIndex: value }); + setOffset={value => { + updateArgs({ offset: value }); }} setPageSize={value => { updateArgs({ pageSize: value }); diff --git a/packages/ui/src/components/TablePagination/TablePagination.tsx b/packages/ui/src/components/TablePagination/TablePagination.tsx index 9fc9ee7207..e7827e6ab9 100644 --- a/packages/ui/src/components/TablePagination/TablePagination.tsx +++ b/packages/ui/src/components/TablePagination/TablePagination.tsx @@ -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={} 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={} aria-label="Next" diff --git a/packages/ui/src/components/TablePagination/types.ts b/packages/ui/src/components/TablePagination/types.ts index 7ee654d3fc..7c60b10a90 100644 --- a/packages/ui/src/components/TablePagination/types.ts +++ b/packages/ui/src/components/TablePagination/types.ts @@ -17,10 +17,10 @@ /** @public */ export interface TablePaginationProps extends React.HTMLAttributes { - pageIndex?: number; + offset?: number; pageSize?: number; setPageSize?: (pageSize: number) => void; - setPageIndex?: (pageIndex: number) => void; + setOffset?: (offset: number) => void; rowCount?: number; onNextPage?: () => void; onPreviousPage?: () => void;