From 243e5e71394cbb0edbd532a448c5accc0414215e Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sat, 6 Dec 2025 20:23:21 +0000 Subject: [PATCH] feat(ui)!: redesign Table component with new useTable hook API Redesigns the Table component to provide a better developer experience with a new useTable hook supporting three pagination modes: complete (all data loaded upfront), offset (server-side), and cursor (server-side). BREAKING CHANGES: - Table component (React Aria wrapper) is renamed to TableRoot - New high-level Table component handles data display, pagination, sorting, and selection - useTable hook completely redesigned with new API New features: - Unified useTable hook with mode discriminator for all pagination patterns (complete, offset, cursor) - Custom page caching for server-side pagination with bidirectional navigation and request cancellation - Debounced query changes to reduce backend load - Stale data preservation with visual indicator during reloads - Row selection with toggle/replace behaviors - Per-row disable control via getIsDisabled MIGRATION GUIDE: 1. Update imports and use the new useTable hook: ```diff -import { Table, useTable } from '@backstage/ui'; -const { data, paginationProps } = useTable({ data: items, pagination: {...} }); +import { Table, useTable, type ColumnConfig } from '@backstage/ui'; +const { tableProps } = useTable({ + mode: 'complete', + getData: () => items, +}); ``` 2. Define columns and render with the new Table API: ```diff - - ... - ... -
- +const columns: ColumnConfig[] = [ + { id: 'name', label: 'Name', isRowHeader: true, cell: item => }, + { id: 'type', label: 'Type', cell: item => }, +]; + + ``` Signed-off-by: Johan Persson --- .changeset/every-lions-pay.md | 44 + .../config/vocabularies/Backstage/accept.txt | 1 + docs-ui/src/snippets/stories-snippets.tsx | 2 +- packages/ui/report.api.md | 359 ++++++- .../ui/src/components/Table/Table.module.css | 5 + .../ui/src/components/Table/Table.stories.tsx | 910 ----------------- .../src/components/Table/components/Table.tsx | 207 +++- .../components/Table/components/TableRoot.tsx | 40 + .../ui/src/components/Table/definition.ts | 3 + .../ui/src/components/Table/hooks/types.ts | 177 +++- .../Table/hooks/useCompletePagination.ts | 150 +++ .../Table/hooks/useCursorPagination.ts | 92 ++ .../Table/hooks/useDebouncedReload.ts | 42 + .../Table/hooks/useOffsetPagination.ts | 105 ++ .../components/Table/hooks/usePageCache.ts | 280 +++++ .../components/Table/hooks/useQueryState.ts | 68 ++ .../Table/hooks/useStableCallback.ts | 31 + .../ui/src/components/Table/hooks/useTable.ts | 221 ++-- packages/ui/src/components/Table/index.ts | 28 +- .../Table/stories/Table.dev.stories.tsx | 965 ++++++++++++++++++ .../Table/stories/Table.docs.stories.tsx | 198 ++++ .../Table/stories/Table.visual.stories.tsx | 340 ++++++ .../Table/{ => stories}/mocked-data1.ts | 101 ++ .../Table/{ => stories}/mocked-data2.ts | 0 .../Table/{ => stories}/mocked-data3.ts | 0 .../Table/{ => stories}/mocked-data4.ts | 11 + .../ui/src/components/Table/stories/utils.tsx | 54 + packages/ui/src/components/Table/types.ts | 89 +- .../TablePagination.stories.tsx | 103 +- .../TablePagination/TablePagination.tsx | 103 +- .../src/components/TablePagination/types.ts | 22 +- 31 files changed, 3497 insertions(+), 1254 deletions(-) create mode 100644 .changeset/every-lions-pay.md delete mode 100644 packages/ui/src/components/Table/Table.stories.tsx create mode 100644 packages/ui/src/components/Table/components/TableRoot.tsx create mode 100644 packages/ui/src/components/Table/hooks/useCompletePagination.ts create mode 100644 packages/ui/src/components/Table/hooks/useCursorPagination.ts create mode 100644 packages/ui/src/components/Table/hooks/useDebouncedReload.ts create mode 100644 packages/ui/src/components/Table/hooks/useOffsetPagination.ts create mode 100644 packages/ui/src/components/Table/hooks/usePageCache.ts create mode 100644 packages/ui/src/components/Table/hooks/useQueryState.ts create mode 100644 packages/ui/src/components/Table/hooks/useStableCallback.ts create mode 100644 packages/ui/src/components/Table/stories/Table.dev.stories.tsx create mode 100644 packages/ui/src/components/Table/stories/Table.docs.stories.tsx create mode 100644 packages/ui/src/components/Table/stories/Table.visual.stories.tsx rename packages/ui/src/components/Table/{ => stories}/mocked-data1.ts (93%) rename packages/ui/src/components/Table/{ => stories}/mocked-data2.ts (100%) rename packages/ui/src/components/Table/{ => stories}/mocked-data3.ts (100%) rename packages/ui/src/components/Table/{ => stories}/mocked-data4.ts (99%) create mode 100644 packages/ui/src/components/Table/stories/utils.tsx diff --git a/.changeset/every-lions-pay.md b/.changeset/every-lions-pay.md new file mode 100644 index 0000000000..9c5a32f4e4 --- /dev/null +++ b/.changeset/every-lions-pay.md @@ -0,0 +1,44 @@ +--- +'@backstage/ui': minor +--- + +**BREAKING**: Redesigned Table component with new `useTable` hook API. + +- The `Table` component (React Aria wrapper) is renamed to `TableRoot` +- New high-level `Table` component that handles data display, pagination, sorting, and selection +- The `useTable` hook is completely redesigned with a new API supporting three pagination modes (complete, offset, cursor) +- New types: `ColumnConfig`, `TableProps`, `TableItem`, `UseTableOptions`, `UseTableResult` + +New features include unified pagination modes, debounced query changes, stale data preservation during reloads, and row selection with toggle/replace behaviors. + +**Migration guide:** + +1. Update imports and use the new `useTable` hook: + +```diff +-import { Table, useTable } from '@backstage/ui'; +-const { data, paginationProps } = useTable({ data: items, pagination: {...} }); ++import { Table, useTable, type ColumnConfig } from '@backstage/ui'; ++const { tableProps } = useTable({ ++ mode: 'complete', ++ getData: () => items, ++}); +``` + +2. Define columns and render with the new Table API: + +```diff +-
+- ... +- ... +-
+- ++const columns: ColumnConfig[] = [ ++ { id: 'name', label: 'Name', isRowHeader: true, cell: item => }, ++ { id: 'type', label: 'Type', cell: item => }, ++]; ++ ++ +``` + +Affected components: Table, TableRoot, TablePagination diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 44ee42a25a..d8c25565ee 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -99,6 +99,7 @@ dataflow dataloader dayjs debounce +debounced debounces debuggability declaratively diff --git a/docs-ui/src/snippets/stories-snippets.tsx b/docs-ui/src/snippets/stories-snippets.tsx index 0cb6119ce6..0507c85c9f 100644 --- a/docs-ui/src/snippets/stories-snippets.tsx +++ b/docs-ui/src/snippets/stories-snippets.tsx @@ -26,7 +26,7 @@ import * as SkeletonStories from '../../../packages/ui/src/components/Skeleton/S import * as CardStories from '../../../packages/ui/src/components/Card/Card.stories'; import * as HeaderStories from '../../../packages/ui/src/components/Header/Header.stories'; import * as HeaderPageStories from '../../../packages/ui/src/components/HeaderPage/HeaderPage.stories'; -import * as TableStories from '../../../packages/ui/src/components/Table/Table.stories'; +import * as TableStories from '../../../packages/ui/src/components/Table/stories/Table.docs.stories'; import * as TagGroupStories from '../../../packages/ui/src/components/TagGroup/TagGroup.stories'; import * as PasswordFieldStories from '../../../packages/ui/src/components/PasswordField/PasswordField.stories'; import * as VisuallyHiddenStories from '../../../packages/ui/src/components/VisuallyHidden/VisuallyHidden.stories'; diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 0a7e289d0a..b7b4d4dc05 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -37,11 +37,12 @@ import { RowProps } from 'react-aria-components'; import type { SearchFieldProps as SearchFieldProps_2 } from 'react-aria-components'; import type { SelectProps as SelectProps_2 } from 'react-aria-components'; import type { SeparatorProps } from 'react-aria-components'; +import type { SortDescriptor as SortDescriptor_2 } from 'react-stately'; import type { SubmenuTriggerProps as SubmenuTriggerProps_2 } from 'react-aria-components'; import type { SwitchProps as SwitchProps_2 } from 'react-aria-components'; import { TableBodyProps } from 'react-aria-components'; import { TableHeaderProps } from 'react-aria-components'; -import { TableProps } from 'react-aria-components'; +import { TableProps as TableProps_2 } from 'react-aria-components'; import type { TabListProps as TabListProps_2 } from 'react-aria-components'; import type { TabPanelProps as TabPanelProps_2 } from 'react-aria-components'; import type { TabProps as TabProps_2 } from 'react-aria-components'; @@ -456,6 +457,26 @@ export type ClassNamesMap = Record; // @public (undocumented) export const Column: (props: ColumnProps) => JSX_2.Element; +// @public (undocumented) +export interface ColumnConfig { + // (undocumented) + cell: (item: T) => ReactNode; + // (undocumented) + header?: () => ReactNode; + // (undocumented) + id: string; + // (undocumented) + isHidden?: boolean; + // (undocumented) + isRowHeader?: boolean; + // (undocumented) + isSortable?: boolean; + // (undocumented) + label: string; + // (undocumented) + width?: number | string; +} + // @public (undocumented) export interface ColumnProps extends Omit { // (undocumented) @@ -523,6 +544,34 @@ export interface ContainerProps { style?: React.CSSProperties; } +// @public (undocumented) +export interface CursorParams { + // (undocumented) + cursor: string | undefined; + // (undocumented) + filter: TFilter | undefined; + // (undocumented) + pageSize: number; + // (undocumented) + search: string; + // (undocumented) + signal: AbortSignal; + // (undocumented) + sort: SortDescriptor | null; +} + +// @public (undocumented) +export interface CursorResponse { + // (undocumented) + data: T[]; + // (undocumented) + nextCursor?: string; + // (undocumented) + prevCursor?: string; + // (undocumented) + totalCount?: number; +} + // @public export type DataAttributesMap = Record; @@ -627,6 +676,14 @@ export interface FieldLabelProps secondaryLabel?: string | null; } +// @public (undocumented) +export interface FilterState { + // (undocumented) + onFilterChange: (filter: TFilter) => void; + // (undocumented) + value: TFilter | undefined; +} + // @public (undocumented) export const Flex: ForwardRefExoticComponent< FlexProps & RefAttributes @@ -1038,6 +1095,36 @@ export const MenuTrigger: (props: MenuTriggerProps) => JSX_2.Element; // @public (undocumented) export interface MenuTriggerProps extends MenuTriggerProps_2 {} +// @public (undocumented) +export interface NoPagination { + // (undocumented) + type: 'none'; +} + +// @public (undocumented) +export interface OffsetParams { + // (undocumented) + filter: TFilter | undefined; + // (undocumented) + offset: number; + // (undocumented) + pageSize: number; + // (undocumented) + search: string; + // (undocumented) + signal: AbortSignal; + // (undocumented) + sort: SortDescriptor | null; +} + +// @public (undocumented) +export interface OffsetResponse { + // (undocumented) + data: T[]; + // (undocumented) + totalCount: number; +} + // @public (undocumented) type Option_2 = { value: string; @@ -1046,6 +1133,46 @@ type Option_2 = { }; export { Option_2 as Option }; +// @public (undocumented) +export interface PagePagination extends TablePaginationProps { + // (undocumented) + type: 'page'; +} + +// @public (undocumented) +export interface PaginationOptions { + // (undocumented) + getLabel?: TablePaginationProps['getLabel']; + // (undocumented) + initialOffset?: number; + // (undocumented) + pageSize?: number; + // (undocumented) + showPageSizeOptions?: boolean; +} + +// @public (undocumented) +export interface QueryOptions { + // (undocumented) + filter?: TFilter; + // (undocumented) + initialFilter?: TFilter; + // (undocumented) + initialSearch?: string; + // (undocumented) + initialSort?: SortDescriptor; + // (undocumented) + onFilterChange?: (filter: TFilter) => void; + // (undocumented) + onSearchChange?: (search: string) => void; + // (undocumented) + onSortChange?: (sort: SortDescriptor) => void; + // (undocumented) + search?: string; + // (undocumented) + sort?: SortDescriptor | null; +} + // @public (undocumented) export const Radio: ForwardRefExoticComponent< RadioProps & RefAttributes @@ -1082,6 +1209,22 @@ export type Responsive = T | Partial>; // @public (undocumented) export function Row(props: RowProps): JSX_2.Element; +// @public (undocumented) +export interface RowConfig { + // (undocumented) + getHref?: (item: T) => string | undefined; + // (undocumented) + getIsDisabled?: (item: T) => boolean; + // (undocumented) + onClick?: (item: T) => void; +} + +// @public (undocumented) +export type RowRenderFn = (params: { + item: T; + index: number; +}) => ReactNode; + // @public (undocumented) export const SearchField: ForwardRefExoticComponent< SearchFieldProps & RefAttributes @@ -1112,6 +1255,14 @@ export interface SearchFieldProps startCollapsed?: boolean; } +// @public (undocumented) +export interface SearchState { + // (undocumented) + onSearchChange: (value: string) => void; + // (undocumented) + value: string; +} + // @public (undocumented) export const Select: ForwardRefExoticComponent< SelectProps<'multiple' | 'single'> & RefAttributes @@ -1171,6 +1322,17 @@ export interface SkeletonProps extends ComponentProps<'div'> { width?: number | string; } +// @public (undocumented) +export type SortDescriptor = SortDescriptor_2; + +// @public (undocumented) +export interface SortState { + // (undocumented) + descriptor: SortDescriptor | null; + // (undocumented) + onSortChange: (descriptor: SortDescriptor) => void; +} + // @public (undocumented) export type Space = | '0.5' @@ -1251,7 +1413,18 @@ export interface SwitchProps extends SwitchProps_2 { export const Tab: (props: TabProps) => JSX_2.Element; // @public (undocumented) -export const Table: (props: TableProps) => JSX_2.Element; +export function Table({ + columnConfig, + data, + loading, + isStale, + error, + pagination, + sort, + rowConfig, + selection, + emptyState, +}: TableProps): JSX_2.Element; // @public (undocumented) export const TableBody: ( @@ -1281,6 +1454,9 @@ export const TableDefinition: { readonly headSelection: 'bui-TableHeadSelection'; readonly cellSelection: 'bui-TableCellSelection'; }; + readonly dataAttributes: { + readonly stale: readonly [true, false]; + }; }; // @public (undocumented) @@ -1288,8 +1464,25 @@ export const TableHeader: ( props: TableHeaderProps, ) => JSX_2.Element; +// @public (undocumented) +export interface TableItem { + // (undocumented) + id: string | number; +} + // @public -export function TablePagination(props: TablePaginationProps): JSX_2.Element; +export function TablePagination({ + pageSize, + offset, + totalCount, + hasNextPage, + hasPreviousPage, + onNextPage, + onPreviousPage, + onPageSizeChange, + showPageSizeOptions, + getLabel, +}: TablePaginationProps): JSX_2.Element; // @public export const TablePaginationDefinition: { @@ -1302,26 +1495,79 @@ export const TablePaginationDefinition: { }; // @public (undocumented) -export interface TablePaginationProps - extends React.HTMLAttributes { +export interface TablePaginationProps { + // (undocumented) + getLabel?: (params: { + pageSize: number; + offset?: number; + totalCount?: number; + }) => string; + // (undocumented) + hasNextPage: boolean; + // (undocumented) + hasPreviousPage: boolean; // (undocumented) offset?: number; // (undocumented) - onNextPage?: () => void; + onNextPage: () => void; // (undocumented) - onPageSizeChange?: (pageSize: number) => void; + onPageSizeChange?: (size: number) => void; // (undocumented) - onPreviousPage?: () => void; + onPreviousPage: () => void; // (undocumented) - pageSize?: number; - // (undocumented) - rowCount?: number; - // (undocumented) - setOffset?: (offset: number) => void; - // (undocumented) - setPageSize?: (pageSize: number) => void; + pageSize: number; // (undocumented) showPageSizeOptions?: boolean; + // (undocumented) + totalCount?: number; +} + +// @public (undocumented) +export type TablePaginationType = NoPagination | PagePagination; + +// @public (undocumented) +export interface TableProps { + // (undocumented) + columnConfig: readonly ColumnConfig[]; + // (undocumented) + data: T[] | undefined; + // (undocumented) + emptyState?: ReactNode; + // (undocumented) + error?: Error; + // (undocumented) + isStale?: boolean; + // (undocumented) + loading?: boolean; + // (undocumented) + pagination: TablePaginationType; + // (undocumented) + rowConfig?: RowConfig | RowRenderFn; + // (undocumented) + selection?: TableSelection; + // (undocumented) + sort?: SortState; +} + +// @public (undocumented) +export const TableRoot: (props: TableRootProps) => JSX_2.Element; + +// @public (undocumented) +export interface TableRootProps extends TableProps_2 { + // (undocumented) + stale?: boolean; +} + +// @public (undocumented) +export interface TableSelection { + // (undocumented) + behavior?: TableProps_2['selectionBehavior']; + // (undocumented) + mode?: TableProps_2['selectionMode']; + // (undocumented) + onSelectionChange?: TableProps_2['onSelectionChange']; + // (undocumented) + selected?: TableProps_2['selectedKeys']; } // @public @@ -1535,48 +1781,69 @@ export const useBreakpoint: () => { down: (key: Breakpoint) => boolean; }; -// @public -export function useTable( - config?: UseTableConfig, -): UseTableResult; +// @public (undocumented) +export function useTable( + options: UseTableOptions, +): UseTableResult; // @public (undocumented) -export interface UseTableConfig { - data?: T[]; - pagination?: UseTablePaginationConfig; +export interface UseTableCompleteOptions + extends QueryOptions { + // (undocumented) + filterFn?: (data: T[], filter: TFilter) => T[]; + // (undocumented) + getData: () => T[] | Promise; + // (undocumented) + mode: 'complete'; + // (undocumented) + paginationOptions?: PaginationOptions; + // (undocumented) + searchFn?: (data: T[], search: string) => T[]; + // (undocumented) + sortFn?: (data: T[], sort: SortDescriptor) => T[]; } // @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; +export interface UseTableCursorOptions + extends QueryOptions { + // (undocumented) + getData: (params: CursorParams) => Promise>; + // (undocumented) + mode: 'cursor'; + // (undocumented) + paginationOptions?: Omit; } // @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; +export interface UseTableOffsetOptions + extends QueryOptions { + // (undocumented) + getData: (params: OffsetParams) => Promise>; + // (undocumented) + mode: 'offset'; + // (undocumented) + paginationOptions?: PaginationOptions; } // @public (undocumented) -export interface UseTableResult { - data?: T[]; - pagination: UseTablePagination; - paginationProps: TablePaginationProps; +export type UseTableOptions = + | UseTableCompleteOptions + | UseTableOffsetOptions + | UseTableCursorOptions; + +// @public (undocumented) +export interface UseTableResult { + // (undocumented) + filter: FilterState; + // (undocumented) + reload: () => void; + // (undocumented) + search: SearchState; + // (undocumented) + tableProps: Omit< + TableProps, + 'columnConfig' | 'rowConfig' | 'selection' | 'emptyState' + >; } // @public (undocumented) diff --git a/packages/ui/src/components/Table/Table.module.css b/packages/ui/src/components/Table/Table.module.css index 001d5b21d1..ea148ae893 100644 --- a/packages/ui/src/components/Table/Table.module.css +++ b/packages/ui/src/components/Table/Table.module.css @@ -22,6 +22,11 @@ caption-side: bottom; border-collapse: collapse; table-layout: fixed; + transition: opacity 0.2s ease-in-out; + + &[data-stale='true'] { + opacity: 0.6; + } } .bui-TableHeader { diff --git a/packages/ui/src/components/Table/Table.stories.tsx b/packages/ui/src/components/Table/Table.stories.tsx deleted file mode 100644 index f7687af1c3..0000000000 --- a/packages/ui/src/components/Table/Table.stories.tsx +++ /dev/null @@ -1,910 +0,0 @@ -/* - * 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 preview from '../../../../../.storybook/preview'; -import { useState } from 'react'; -import type { StoryFn } from '@storybook/react-vite'; -import { type Selection } from 'react-aria-components'; -import { - Table, - TableHeader, - Column, - TableBody, - Row, - Cell, - CellText, - CellProfile, - useTable, -} from '.'; -import { RadioGroup, Radio } from '../RadioGroup'; -import { Flex } from '../Flex'; -import { MemoryRouter } from 'react-router-dom'; -import { data as data1Raw } from './mocked-data1'; -import { data as data2 } from './mocked-data2'; -import { data as data3 } from './mocked-data3'; -import { data as data4 } from './mocked-data4'; -import { RiCactusLine } from '@remixicon/react'; -import { TablePagination } from '../TablePagination'; -import { Text } from '../Text'; - -const meta = preview.meta({ - title: 'Backstage UI/Table', - decorators: [ - (Story: StoryFn) => ( - - - - ), - ], -}); - -// Added this fix to fix Chromatic timeout error. This bug is due to rerendering the table with too many rows. -// Work in progress to fix it here - https://github.com/backstage/backstage/pull/30687 -const data1 = data1Raw.slice(0, 10); - -export const TableOnly = meta.story({ - render: () => { - return ( -
- - Name - Owner - Type - Lifecycle - - - {data1.map(item => ( - - } - description={item.description} - /> - - - - - ))} - -
- ); - }, -}); - -export const WithPaginationUncontrolled = meta.story({ - render: () => { - const { data, paginationProps } = useTable({ data: data1 }); - - return ( - <> - - - Name - Owner - Type - Lifecycle - - - {data?.map(item => ( - - } - description={item.description} - /> - - - - - ))} - -
- - - ); - }, -}); - -export const WithPaginationControlled = meta.story({ - render: () => { - const [offset, setOffset] = useState(0); - const [pageSize, setPageSize] = useState(5); - - 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 ( - <> - - - Band name - Genre - Year formed - Albums - - - {data?.map(item => ( - - - - - - - ))} - -
- -
- Current state: offset={offset}, pageSize={pageSize} -
- - ); - }, -}); - -export const Sorting = meta.story({ - render: () => { - return ( - - - - Name - - Owner - Type - Lifecycle - - - {data1.map(item => ( - - } - description={item.description} - /> - - - - - ))} - -
- ); - }, -}); - -export const TableRockBand = meta.story({ - render: () => { - const { data, paginationProps } = useTable({ - data: data4, - pagination: { - defaultPageSize: 5, - }, - }); - - return ( - <> - - - Band name - Genre - Year formed - Albums - - - {data?.map(item => ( - - - - - - - ))} - -
- - - ); - }, -}); - -export const RowClick = meta.story({ - render: () => { - const { data, paginationProps } = useTable({ - data: data4, - pagination: { - defaultPageSize: 5, - }, - }); - - return ( - <> - - - Band name - Genre - Year formed - Albums - - - {data?.map(item => ( - alert('Row clicked')}> - - - - - - ))} - -
- - - ); - }, -}); - -export const RowLink = meta.story({ - render: () => { - const { data, paginationProps } = useTable({ - data: data4, - pagination: { - defaultPageSize: 5, - }, - }); - - return ( - <> - - - Band name - Genre - Year formed - Albums - - - {data?.map(item => ( - - - - - - - ))} - -
- - - ); - }, -}); - -export const CellComponent = meta.story({ - name: 'Cell', - render: () => { - return ( - - - Name - - - - Hello world - - - - This is a very long text that demonstrates how the Cell component - handles lengthy content. It should wrap appropriately and maintain - proper styling even when the text extends beyond the normal cell - width. This helps ensure that the table remains readable and - visually consistent regardless of the content length. - - - - Hello world - - -
- ); - }, -}); - -export const CellTextComponent = meta.story({ - name: 'CellText', - render: () => { - return ( - - - Name - - - {data2.map(item => ( - - - - ))} - -
- ); - }, -}); - -export const CellProfileComponent = meta.story({ - name: 'CellProfile', - render: () => { - return ( - - - Name - - - {data3.map(item => ( - - - - ))} - -
- ); - }, -}); - -export const SelectionSingleToggle = meta.story({ - render: () => { - const [selectedKeys, setSelectedKeys] = useState(new Set([])); - - return ( - - - Name - Owner - Type - - - - - - - - - - - - - - - - - - -
- ); - }, -}); - -export const SelectionMultiToggle = meta.story({ - render: () => { - const [selectedKeys, setSelectedKeys] = useState(new Set([])); - - return ( - - - Name - Owner - Type - - - - - - - - - - - - - - - - - - -
- ); - }, -}); - -export const SelectionSingleReplace = meta.story({ - render: () => { - const [selectedKeys, setSelectedKeys] = useState(new Set([])); - - return ( - - - Name - Owner - Type - - - - - - - - - - - - - - - - - - -
- ); - }, -}); - -export const SelectionMultiReplace = meta.story({ - render: () => { - const [selectedKeys, setSelectedKeys] = useState(new Set([])); - - return ( - - - Name - Owner - Type - - - - - - - - - - - - - - - - - - -
- ); - }, -}); - -export const SelectionToggleWithActions = meta.story({ - render: () => { - const [selectedKeys, setSelectedKeys] = useState(new Set([])); - - return ( - alert(`Opening ${key}`)} - > - - Name - Owner - Type - - - - - - - - - - - - - - - - - - -
- ); - }, -}); - -export const SelectionReplaceWithActions = meta.story({ - render: () => { - const [selectedKeys, setSelectedKeys] = useState(new Set([])); - - return ( - alert(`Opening ${key}`)} - > - - Name - Owner - Type - - - - - - - - - - - - - - - - - - -
- ); - }, -}); - -export const SelectionToggleWithLinks = meta.story({ - render: () => { - const [selectedKeys, setSelectedKeys] = useState(new Set([])); - - return ( - - - Name - Owner - Type - - - - - - - - - - - - - - - - - - -
- ); - }, -}); - -export const SelectionReplaceWithLinks = meta.story({ - render: () => { - const [selectedKeys, setSelectedKeys] = useState(new Set([])); - - return ( - - - Name - Owner - Type - - - - - - - - - - - - - - - - - - -
- ); - }, -}); - -export const SelectionWithDisabledRows = meta.story({ - render: () => { - const [selectedKeys, setSelectedKeys] = useState(new Set([])); - - return ( - - - Name - Owner - Type - - - - - - - - - - - - - - - - - - -
- ); - }, -}); - -export const SelectionWithPagination = meta.story({ - render: () => { - const [selectedKeys, setSelectedKeys] = useState(new Set([])); - - const { data, paginationProps } = useTable({ - data: data1, - pagination: { - defaultPageSize: 5, - }, - }); - - return ( - <> - - - Name - Owner - Type - - - {data?.map(item => ( - - - - - - ))} - -
- - - ); - }, -}); - -export const SelectionModePlayground = meta.story({ - render: () => { - const [selectionMode, setSelectionMode] = useState<'single' | 'multiple'>( - 'multiple', - ); - const [selectedKeys, setSelectedKeys] = useState(new Set([])); - - return ( - - - - Name - Owner - Type - - - - - - - - - - - - - - - - - - -
-
- - Selection mode: - - { - setSelectionMode(value as 'single' | 'multiple'); - setSelectedKeys(new Set([])); - }} - > - single - multiple - -
-
- ); - }, -}); - -export const SelectionBehaviorPlayground = meta.story({ - render: () => { - const [selectionBehavior, setSelectionBehavior] = useState< - 'toggle' | 'replace' - >('toggle'); - const [selectedKeys, setSelectedKeys] = useState(new Set([])); - - return ( - - - - Name - Owner - Type - - - - - - - - - - - - - - - - - - -
-
- - Selection behavior: - - { - setSelectionBehavior(value as 'toggle' | 'replace'); - setSelectedKeys(new Set([])); - }} - > - toggle - replace - -
-
- ); - }, -}); diff --git a/packages/ui/src/components/Table/components/Table.tsx b/packages/ui/src/components/Table/components/Table.tsx index ffd0ab231d..59020dc2b0 100644 --- a/packages/ui/src/components/Table/components/Table.tsx +++ b/packages/ui/src/components/Table/components/Table.tsx @@ -14,24 +14,199 @@ * limitations under the License. */ -import { useStyles } from '../../../hooks/useStyles'; -import { TableDefinition } from '../definition'; -import { - Table as ReactAriaTable, - type TableProps, -} from 'react-aria-components'; -import styles from '../Table.module.css'; -import clsx from 'clsx'; +import type { Key } from 'react-aria-components'; +import { TableRoot } from './TableRoot'; +import { TableHeader } from './TableHeader'; +import { TableBody } from './TableBody'; +import { Row } from './Row'; +import { Column } from './Column'; +import { TablePagination } from '../../TablePagination'; +import type { + TableProps, + TableItem, + RowConfig, + RowRenderFn, + TablePaginationType, +} from '../types'; +import { Fragment, useId, useMemo } from 'react'; +import { VisuallyHidden } from '../../VisuallyHidden'; +import { Flex } from '../../Flex'; + +function isRowRenderFn( + rowConfig: RowConfig | RowRenderFn | undefined, +): rowConfig is RowRenderFn { + return typeof rowConfig === 'function'; +} + +function useDisabledRows({ + data, + rowConfig, +}: Pick, 'data' | 'rowConfig'>): Set | undefined { + return useMemo(() => { + if (!data || typeof rowConfig === 'function' || !rowConfig?.getIsDisabled) { + return; + } + + return data.reduce>((set, item) => { + const isDisabled = rowConfig.getIsDisabled?.(item); + if (isDisabled) { + set.add(String(item.id)); + } + return set; + }, new Set()); + }, [data, rowConfig]); +} + +function useLiveRegionLabel( + pagination: TablePaginationType, + isStale: boolean, + hasData: boolean, +): string { + if (!hasData || pagination.type === 'none') { + return ''; + } + + const { pageSize, offset, totalCount, getLabel } = pagination; + + if (isStale) { + return 'Loading table data.'; + } + + let liveRegionLabel = 'Table page loaded. '; + + if (getLabel) { + liveRegionLabel += getLabel({ pageSize, offset, totalCount }); + } else if (offset !== undefined) { + const fromCount = offset + 1; + const toCount = Math.min(offset + pageSize, totalCount ?? 0); + liveRegionLabel += `Showing ${fromCount} to ${toCount} of ${totalCount}`; + } + return liveRegionLabel; +} /** @public */ -export const Table = (props: TableProps) => { - const { classNames, cleanedProps } = useStyles(TableDefinition, props); +export function Table({ + columnConfig, + data, + loading = false, + isStale = false, + error, + pagination, + sort, + rowConfig, + selection, + emptyState, +}: TableProps) { + const liveRegionId = useId(); + + const visibleColumns = useMemo( + () => columnConfig.filter(col => !col.isHidden), + [columnConfig], + ); + const disabledRows = useDisabledRows({ data, rowConfig }); + + const { + mode: selectionMode, + selected: selectedKeys, + behavior: selectionBehavior, + onSelectionChange, + } = selection || {}; + + if (loading && !data) { + return
Loading...
; + } + + if (error) { + return
Error: {error.message}
; + } + + const liveRegionLabel = useLiveRegionLabel( + pagination, + isStale, + data !== undefined, + ); return ( - +
+ + {liveRegionLabel} + + + + + {column => + column.header ? ( + <>{column.header()} + ) : ( + + {column.label} + + ) + } + + {emptyState} : undefined + } + > + {item => { + const itemIndex = data?.indexOf(item) ?? -1; + + if (isRowRenderFn(rowConfig)) { + return rowConfig({ + item, + index: itemIndex, + }); + } + + return ( + rowConfig?.onClick?.(item) + : undefined + } + > + {column => ( + {column.cell(item)} + )} + + ); + }} + + + {pagination.type === 'page' && ( + + )} +
); -}; +} diff --git a/packages/ui/src/components/Table/components/TableRoot.tsx b/packages/ui/src/components/Table/components/TableRoot.tsx new file mode 100644 index 0000000000..95e80dcf51 --- /dev/null +++ b/packages/ui/src/components/Table/components/TableRoot.tsx @@ -0,0 +1,40 @@ +/* + * 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 { useStyles } from '../../../hooks/useStyles'; +import { TableDefinition } from '../definition'; +import { Table as ReactAriaTable } from 'react-aria-components'; +import styles from '../Table.module.css'; +import clsx from 'clsx'; +import { TableRootProps } from '../types'; + +/** @public */ +export const TableRoot = (props: TableRootProps) => { + const { classNames, dataAttributes, cleanedProps } = useStyles( + TableDefinition, + props, + ); + + return ( + + ); +}; diff --git a/packages/ui/src/components/Table/definition.ts b/packages/ui/src/components/Table/definition.ts index 313604902c..ca4fca999a 100644 --- a/packages/ui/src/components/Table/definition.ts +++ b/packages/ui/src/components/Table/definition.ts @@ -42,4 +42,7 @@ export const TableDefinition = { headSelection: 'bui-TableHeadSelection', cellSelection: 'bui-TableCellSelection', }, + dataAttributes: { + stale: [true, false] as const, + }, } as const satisfies ComponentDefinition; diff --git a/packages/ui/src/components/Table/hooks/types.ts b/packages/ui/src/components/Table/hooks/types.ts index 8c93d678fc..f6311b7ebe 100644 --- a/packages/ui/src/components/Table/hooks/types.ts +++ b/packages/ui/src/components/Table/hooks/types.ts @@ -15,73 +15,144 @@ */ import type { TablePaginationProps } from '../../TablePagination/types'; +import type { SortDescriptor, TableItem, TableProps } from '../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; +export interface FilterState { + value: TFilter | undefined; + onFilterChange: (filter: TFilter) => void; +} - // Controlled pagination with offset/pageSize (Backstage style) - /** Current offset. When provided, pagination is controlled */ - offset?: number; - /** Current page size. When provided, pagination is controlled */ +/** @public */ +export interface SearchState { + value: string; + onSearchChange: (value: string) => void; +} + +/** @public */ +export interface QueryOptions { + initialSort?: SortDescriptor; + sort?: SortDescriptor | null; + onSortChange?: (sort: SortDescriptor) => void; + + initialFilter?: TFilter; + filter?: TFilter; + onFilterChange?: (filter: TFilter) => void; + + initialSearch?: string; + search?: string; + onSearchChange?: (search: string) => void; +} + +/** @public */ +export interface PaginationOptions { 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 */ + initialOffset?: number; showPageSizeOptions?: boolean; + getLabel?: TablePaginationProps['getLabel']; } /** @public */ -export interface UseTablePagination { - /** Props to pass to TablePagination component */ - paginationProps: TablePaginationProps; - /** Current offset */ +export interface OffsetParams { 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; + sort: SortDescriptor | null; + filter: TFilter | undefined; + search: string; + signal: AbortSignal; } /** @public */ -export interface UseTableConfig { - /** Full dataset - when provided, rowCount is calculated automatically and sliced data is returned */ - data?: T[]; - /** Pagination configuration */ - pagination?: UseTablePaginationConfig; +export interface CursorParams { + cursor: string | undefined; + pageSize: number; + sort: SortDescriptor | null; + filter: TFilter | undefined; + search: string; + signal: AbortSignal; } /** @public */ -export interface UseTableResult { - /** Sliced data for current page */ - data?: T[]; - /** Props to pass to TablePagination component */ - paginationProps: TablePaginationProps; - /** Pagination utilities */ - pagination: UseTablePagination; +export interface OffsetResponse { + data: T[]; + totalCount: number; +} + +/** @public */ +export interface CursorResponse { + data: T[]; + nextCursor?: string; + prevCursor?: string; + totalCount?: number; +} + +/** @public */ +export interface UseTableCompleteOptions + extends QueryOptions { + mode: 'complete'; + getData: () => T[] | Promise; + paginationOptions?: PaginationOptions; + sortFn?: (data: T[], sort: SortDescriptor) => T[]; + filterFn?: (data: T[], filter: TFilter) => T[]; + searchFn?: (data: T[], search: string) => T[]; +} + +/** @public */ +export interface UseTableOffsetOptions + extends QueryOptions { + mode: 'offset'; + getData: (params: OffsetParams) => Promise>; + paginationOptions?: PaginationOptions; +} + +/** @public */ +export interface UseTableCursorOptions + extends QueryOptions { + mode: 'cursor'; + getData: (params: CursorParams) => Promise>; + paginationOptions?: Omit; +} + +/** @public */ +export type UseTableOptions = + | UseTableCompleteOptions + | UseTableOffsetOptions + | UseTableCursorOptions; + +/** @public */ +export interface UseTableResult { + tableProps: Omit< + TableProps, + 'columnConfig' | 'rowConfig' | 'selection' | 'emptyState' + >; + reload: () => void; + filter: FilterState; + search: SearchState; +} + +/** @internal */ +export interface PaginationResult { + data: T[] | undefined; + loading: boolean; + error: Error | undefined; + totalCount: number | undefined; + offset?: number; + pageSize: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + onNextPage: () => void; + onPreviousPage: () => void; + onPageSizeChange: (size: number) => void; +} + +/** @internal */ +export interface QueryState { + sort: SortDescriptor | null; + setSort: (sort: SortDescriptor) => void; + + filter: TFilter | undefined; + setFilter: (filter: TFilter) => void; + + search: string; + setSearch: (search: string) => void; } diff --git a/packages/ui/src/components/Table/hooks/useCompletePagination.ts b/packages/ui/src/components/Table/hooks/useCompletePagination.ts new file mode 100644 index 0000000000..3f199335d5 --- /dev/null +++ b/packages/ui/src/components/Table/hooks/useCompletePagination.ts @@ -0,0 +1,150 @@ +/* + * 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, useCallback, useMemo, useEffect, useRef } from 'react'; +import type { TableItem } from '../types'; +import type { + PaginationResult, + QueryState, + UseTableCompleteOptions, +} from './types'; +import { useStableCallback } from './useStableCallback'; + +/** @internal */ +export function useCompletePagination( + options: UseTableCompleteOptions, + query: QueryState, +): PaginationResult & { reload: () => void } { + const { + getData: getDataProp, + paginationOptions = {}, + sortFn, + filterFn, + searchFn, + } = options; + const { pageSize: defaultPageSize = 20, initialOffset = 0 } = + paginationOptions; + + const getData = useStableCallback(getDataProp); + const { sort, filter, search } = query; + + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(undefined); + const [loadCount, setLoadCount] = useState(0); + + const [offset, setOffset] = useState(initialOffset); + const [pageSize, setPageSize] = useState(defaultPageSize); + + // Load data on mount and when loadCount changes (reload trigger) + useEffect(() => { + let cancelled = false; + setIsLoading(true); + setError(undefined); + + (async () => { + try { + const result = getData(); + const data = result instanceof Promise ? await result : result; + if (!cancelled) { + setItems(data); + setIsLoading(false); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err : new Error(String(err))); + setIsLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [getData, loadCount]); + + // Reset offset when query changes (query object is memoized) + const prevQueryRef = useRef(query); + useEffect(() => { + if (prevQueryRef.current !== query) { + prevQueryRef.current = query; + setOffset(0); + } + }, [query]); + + // Process data client-side (filter, search, sort) + const processedData = useMemo(() => { + let result = [...items]; + if (filter !== undefined && filterFn) { + result = filterFn(result, filter); + } + if (search && searchFn) { + result = searchFn(result, search); + } + if (sort && sortFn) { + result = sortFn(result, sort); + } + return result; + }, [items, sort, filter, search, filterFn, searchFn, sortFn]); + + const totalCount = processedData.length; + + // Paginate the processed data + const paginatedData = useMemo( + () => processedData.slice(offset, offset + pageSize), + [processedData, offset, pageSize], + ); + + const hasNextPage = offset + pageSize < totalCount; + const hasPreviousPage = offset > 0; + + const onNextPage = useCallback(() => { + if (offset + pageSize < totalCount) { + setOffset(offset + pageSize); + } + }, [offset, pageSize, totalCount]); + + const onPreviousPage = useCallback(() => { + if (offset > 0) { + setOffset(Math.max(0, offset - pageSize)); + } + }, [offset, pageSize]); + + const onPageSizeChange = useCallback((newSize: number) => { + setPageSize(newSize); + setOffset(0); + }, []); + + const reload = useCallback(() => { + setOffset(0); + setLoadCount(c => c + 1); + }, []); + + return { + data: paginatedData, + loading: isLoading, + error, + totalCount, + offset, + pageSize, + hasNextPage, + hasPreviousPage, + onNextPage, + onPreviousPage, + onPageSizeChange, + reload, + }; +} diff --git a/packages/ui/src/components/Table/hooks/useCursorPagination.ts b/packages/ui/src/components/Table/hooks/useCursorPagination.ts new file mode 100644 index 0000000000..007f39023f --- /dev/null +++ b/packages/ui/src/components/Table/hooks/useCursorPagination.ts @@ -0,0 +1,92 @@ +/* + * 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, useCallback } from 'react'; +import type { TableItem } from '../types'; +import type { + UseTableCursorOptions, + CursorParams, + QueryState, + PaginationResult, +} from './types'; +import { usePageCache } from './usePageCache'; +import { useStableCallback } from './useStableCallback'; +import { useDebouncedReload } from './useDebouncedReload'; + +export function useCursorPagination( + options: UseTableCursorOptions, + query: QueryState, +): PaginationResult & { reload: () => void } { + const { getData: getDataProp, paginationOptions = {} } = options; + const { pageSize: defaultPageSize = 20 } = paginationOptions; + + const getData = useStableCallback(getDataProp); + const { sort, filter, search } = query; + + const [pageSize, setPageSize] = useState(defaultPageSize); + + const wrappedGetData = useCallback( + async ({ + cursor, + signal, + }: { + cursor: string | undefined; + signal: AbortSignal; + }) => { + const params: CursorParams = { + cursor, + pageSize, + sort, + filter, + search, + signal, + }; + + const response = await getData(params); + + return { + data: response.data, + prevCursor: response.prevCursor, + nextCursor: response.nextCursor, + totalCount: response.totalCount, + }; + }, + [getData, pageSize, sort, filter, search], + ); + + const cache = usePageCache({ getData: wrappedGetData }); + + useDebouncedReload(query, pageSize, cache.reload); + + const onPageSizeChange = useCallback( + (newSize: number) => setPageSize(newSize), + [], + ); + + return { + data: cache.data, + loading: cache.loading, + error: cache.error, + totalCount: cache.totalCount, + offset: undefined, + pageSize, + hasNextPage: cache.hasNextPage, + hasPreviousPage: cache.hasPreviousPage, + onNextPage: cache.onNextPage, + onPreviousPage: cache.onPreviousPage, + onPageSizeChange, + reload: cache.reload, + }; +} diff --git a/packages/ui/src/components/Table/hooks/useDebouncedReload.ts b/packages/ui/src/components/Table/hooks/useDebouncedReload.ts new file mode 100644 index 0000000000..d90afa8861 --- /dev/null +++ b/packages/ui/src/components/Table/hooks/useDebouncedReload.ts @@ -0,0 +1,42 @@ +/* + * 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 { useEffect, useRef } from 'react'; +import type { QueryState } from './types'; + +/** + * Triggers a debounced reload when query or pageSize changes. + * Debouncing reduces backend load during rapid changes (e.g., typing in search). + */ +/** @internal */ +export function useDebouncedReload( + query: QueryState, + pageSize: number, + reload: () => void, + delay: number = 200, +): void { + const prevDepsRef = useRef({ query, pageSize }); + + useEffect(() => { + const prev = prevDepsRef.current; + if (prev.query !== query || prev.pageSize !== pageSize) { + prevDepsRef.current = { query, pageSize }; + const timer = setTimeout(reload, delay); + return () => clearTimeout(timer); + } + return undefined; + }, [query, pageSize, reload, delay]); +} diff --git a/packages/ui/src/components/Table/hooks/useOffsetPagination.ts b/packages/ui/src/components/Table/hooks/useOffsetPagination.ts new file mode 100644 index 0000000000..c5844ec018 --- /dev/null +++ b/packages/ui/src/components/Table/hooks/useOffsetPagination.ts @@ -0,0 +1,105 @@ +/* + * 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, useCallback } from 'react'; +import type { TableItem } from '../types'; +import type { + UseTableOffsetOptions, + OffsetParams, + QueryState, + PaginationResult, +} from './types'; +import { usePageCache } from './usePageCache'; +import { useStableCallback } from './useStableCallback'; +import { useDebouncedReload } from './useDebouncedReload'; + +export function useOffsetPagination( + options: UseTableOffsetOptions, + query: QueryState, +): PaginationResult & { reload: () => void } { + const { getData: getDataProp, paginationOptions = {} } = options; + const { pageSize: defaultPageSize = 20, initialOffset = 0 } = + paginationOptions; + + const getData = useStableCallback(getDataProp); + const { sort, filter, search } = query; + + const [pageSize, setPageSize] = useState(defaultPageSize); + + const wrappedGetData = useCallback( + async ({ + cursor, + signal, + }: { + cursor: number | undefined; + signal: AbortSignal; + }) => { + const currentOffset = cursor ?? 0; + + const params: OffsetParams = { + offset: currentOffset, + pageSize, + sort, + filter, + search, + signal, + }; + + const response = await getData(params); + + const prevCursor = + currentOffset > 0 ? Math.max(0, currentOffset - pageSize) : undefined; + const nextCursor = + currentOffset + pageSize < response.totalCount + ? currentOffset + pageSize + : undefined; + + return { + data: response.data, + prevCursor, + nextCursor, + totalCount: response.totalCount, + }; + }, + [getData, pageSize, sort, filter, search], + ); + + const cache = usePageCache({ + getData: wrappedGetData, + initialCurrentCursor: initialOffset > 0 ? initialOffset : undefined, + }); + + useDebouncedReload(query, pageSize, cache.reload); + + const onPageSizeChange = useCallback( + (newSize: number) => setPageSize(newSize), + [], + ); + + return { + data: cache.data, + loading: cache.loading, + error: cache.error, + totalCount: cache.totalCount, + offset: cache.currentCursor ?? 0, + pageSize, + hasNextPage: cache.hasNextPage, + hasPreviousPage: cache.hasPreviousPage, + onNextPage: cache.onNextPage, + onPreviousPage: cache.onPreviousPage, + onPageSizeChange, + reload: cache.reload, + }; +} diff --git a/packages/ui/src/components/Table/hooks/usePageCache.ts b/packages/ui/src/components/Table/hooks/usePageCache.ts new file mode 100644 index 0000000000..9bf864749b --- /dev/null +++ b/packages/ui/src/components/Table/hooks/usePageCache.ts @@ -0,0 +1,280 @@ +/* + * 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, useCallback, useRef, useEffect } from 'react'; + +const FIRST_PAGE_CURSOR = Symbol('firstPage'); + +type CursorType = string | number; + +type InternalCursor = + | TCursor + | typeof FIRST_PAGE_CURSOR; + +interface PageEntry { + data: T[] | undefined; + nextCursor: InternalCursor | undefined; + prevCursor: InternalCursor | undefined; +} + +interface GetDataResult { + data: T[]; + nextCursor?: TCursor; + prevCursor?: TCursor; + totalCount?: number; +} + +/** @internal */ +export interface UsePageCacheOptions { + getData: (params: { + cursor: TCursor | undefined; + signal: AbortSignal; + }) => Promise>; + initialCurrentCursor?: TCursor; +} + +/** @internal */ +export interface UsePageCacheResult { + loading: boolean; + error: Error | undefined; + data: T[] | undefined; + totalCount: number | undefined; + currentCursor: TCursor | undefined; + hasPreviousPage: boolean; + onPreviousPage: () => void; + hasNextPage: boolean; + onNextPage: () => void; + reload: (options?: { keepCurrentCursor?: boolean }) => void; +} + +type Direction = 'mount' | 'reset' | 'refresh' | 'next' | 'prev'; + +class PageCacheStore { + private cache = new Map, PageEntry>(); + + get(cursor: InternalCursor): PageEntry | undefined { + return this.cache.get(cursor); + } + + getOrCreate(cursor: InternalCursor): PageEntry { + const existing = this.cache.get(cursor); + if (existing) { + return existing; + } + const entry: PageEntry = { + data: undefined, + nextCursor: undefined, + prevCursor: undefined, + }; + this.cache.set(cursor, entry); + return entry; + } + + clear() { + this.cache.clear(); + } + + getTargetCursor( + direction: Direction, + currentCursor: InternalCursor, + initialCurrentCursor: TCursor | undefined, + ): InternalCursor | undefined { + if (direction === 'mount') { + return toInternalCursor(initialCurrentCursor); + } + if (direction === 'reset') { + return FIRST_PAGE_CURSOR; + } + if (direction === 'refresh') { + return currentCursor; + } + const currentEntry = this.cache.get(currentCursor); + + if (!currentEntry) { + return; + } + + return direction === 'next' + ? currentEntry.nextCursor + : currentEntry.prevCursor; + } + + linkEntryToSource( + entry: PageEntry, + direction: Direction, + currentCursor: InternalCursor, + ) { + if (direction === 'next') { + entry.prevCursor = currentCursor; + } else if (direction === 'prev') { + entry.nextCursor = currentCursor; + } + } +} + +function toInternalCursor( + cursor: TCursor | undefined, +): InternalCursor { + return cursor === undefined ? FIRST_PAGE_CURSOR : cursor; +} + +function toExternalCursor( + cursor: InternalCursor, +): TCursor | undefined { + return cursor === FIRST_PAGE_CURSOR ? undefined : cursor; +} + +/** @internal */ +export function usePageCache( + options: UsePageCacheOptions, +): UsePageCacheResult { + const { getData, initialCurrentCursor } = options; + + const [currentCursor, setCurrentCursor] = useState>( + () => toInternalCursor(initialCurrentCursor), + ); + + const cacheStore = useRef(new PageCacheStore()).current; + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(undefined); + const [totalCount, setTotalCount] = useState(undefined); + + const abortControllerRef = useRef(null); + + const currentPage = cacheStore.get(currentCursor); + const data = currentPage?.data; + const hasNextPage = currentPage?.nextCursor !== undefined; + const hasPreviousPage = currentPage?.prevCursor !== undefined; + + const goToPage = useCallback( + async (direction: Direction) => { + const targetCursor = cacheStore.getTargetCursor( + direction, + currentCursor, + initialCurrentCursor, + ); + + if (!targetCursor) { + return; + } + + const existingEntry = cacheStore.get(targetCursor); + if (existingEntry?.data !== undefined) { + setCurrentCursor(targetCursor); + return; + } + + const entry = cacheStore.getOrCreate(targetCursor); + cacheStore.linkEntryToSource(entry, direction, currentCursor); + setCurrentCursor(targetCursor); + + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setLoading(true); + setError(undefined); + + try { + const result = await getData({ + cursor: toExternalCursor(targetCursor), + signal: abortController.signal, + }); + + if (abortController.signal.aborted) { + return; + } + + entry.data = result.data; + + if (entry.nextCursor === undefined && result.nextCursor !== undefined) { + entry.nextCursor = result.nextCursor; + } + if (entry.prevCursor === undefined && result.prevCursor !== undefined) { + entry.prevCursor = result.prevCursor; + } + + if (result.totalCount !== undefined) { + setTotalCount(result.totalCount); + } + + setLoading(false); + } catch (err) { + if (abortController.signal.aborted) { + return; + } + + setError(err instanceof Error ? err : new Error(String(err))); + setLoading(false); + } + }, + [getData, initialCurrentCursor, currentCursor, cacheStore], + ); + + useEffect(() => { + goToPage('mount'); + + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + const onNextPage = useCallback(() => { + if (loading) return; + const page = cacheStore.get(currentCursor); + if (!page?.nextCursor) return; + goToPage('next'); + }, [loading, currentCursor, goToPage, cacheStore]); + + const onPreviousPage = useCallback(() => { + if (loading) return; + const page = cacheStore.get(currentCursor); + if (!page?.prevCursor) return; + goToPage('prev'); + }, [loading, currentCursor, goToPage, cacheStore]); + + const reload = useCallback( + (reloadOptions?: { keepCurrentCursor?: boolean }) => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + cacheStore.clear(); + + goToPage(reloadOptions?.keepCurrentCursor ? 'refresh' : 'reset'); + }, + [goToPage, cacheStore], + ); + + return { + loading, + error, + data, + totalCount, + currentCursor: toExternalCursor(currentCursor), + hasPreviousPage, + onPreviousPage, + hasNextPage, + onNextPage, + reload, + }; +} diff --git a/packages/ui/src/components/Table/hooks/useQueryState.ts b/packages/ui/src/components/Table/hooks/useQueryState.ts new file mode 100644 index 0000000000..308d9fd385 --- /dev/null +++ b/packages/ui/src/components/Table/hooks/useQueryState.ts @@ -0,0 +1,68 @@ +/* + * 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 { useMemo, useState, useCallback } from 'react'; +import type { QueryOptions, QueryState } from './types'; + +function useControlledStateHelper( + initialValue: TInitial, + controlledValue: TControlled | undefined, + onChange: ((value: T) => void) | undefined, +) { + const [internalValue, setInternalValue] = useState(initialValue); + + const value = controlledValue !== undefined ? controlledValue : internalValue; + + const setValue = useCallback( + (newValue: T) => { + if (controlledValue === undefined) { + setInternalValue(newValue as unknown as TInitial); + } + if (onChange) { + onChange(newValue); + } + }, + [controlledValue, onChange], + ); + + return [value, setValue] as const; +} + +/** @internal */ +export function useQueryState( + options: QueryOptions, +): QueryState { + const [sort, setSort] = useControlledStateHelper( + options.initialSort ?? null, + options.sort, + options.onSortChange, + ); + const [filter, setFilter] = useControlledStateHelper( + options.initialFilter, + options.filter, + options.onFilterChange, + ); + const [search, setSearch] = useControlledStateHelper( + options.initialSearch ?? '', + options.search, + options.onSearchChange, + ); + + return useMemo( + () => ({ sort, setSort, filter, setFilter, search, setSearch }), + [sort, setSort, filter, setFilter, search, setSearch], + ); +} diff --git a/packages/ui/src/components/Table/hooks/useStableCallback.ts b/packages/ui/src/components/Table/hooks/useStableCallback.ts new file mode 100644 index 0000000000..1764e07dd0 --- /dev/null +++ b/packages/ui/src/components/Table/hooks/useStableCallback.ts @@ -0,0 +1,31 @@ +/* + * 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 { useRef, useCallback } from 'react'; + +/** + * Returns a stable callback reference that always calls the latest version + * of the provided function. Useful for callbacks passed as props that may + * change on every render but shouldn't trigger effect re-runs. + * + * @internal + */ +export function useStableCallback any>(fn: T): T { + const ref = useRef(fn); + ref.current = fn; + + return useCallback((...args: Parameters) => ref.current(...args), []) as T; +} diff --git a/packages/ui/src/components/Table/hooks/useTable.ts b/packages/ui/src/components/Table/hooks/useTable.ts index cd1ddd5c9c..e024f8a335 100644 --- a/packages/ui/src/components/Table/hooks/useTable.ts +++ b/packages/ui/src/components/Table/hooks/useTable.ts @@ -13,154 +13,119 @@ * 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 { useMemo, useRef } from 'react'; +import type { SortState, TableItem, TableProps } from '../types'; import type { - UseTableConfig, + PaginationOptions, + PaginationResult, + UseTableOptions, UseTableResult, - UseTablePagination, } from './types'; +import { useQueryState } from './useQueryState'; +import { useCompletePagination } from './useCompletePagination'; +import { useCursorPagination } from './useCursorPagination'; +import { useOffsetPagination } from './useOffsetPagination'; -/** - * 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; +function useTableProps( + paginationResult: PaginationResult, + sortState: SortState, + paginationOptions: PaginationOptions = {}, +): Omit< + TableProps, + 'columnConfig' | 'rowConfig' | 'selection' | 'emptyState' +> { + const { showPageSizeOptions = true, getLabel } = paginationOptions; - const { - rowCount: providedRowCount, - offset: controlledOffset, - pageSize: controlledPageSize, - onOffsetChange, - onPageSizeChange, - defaultPageSize = 10, - defaultOffset = 0, - onNextPage, - onPreviousPage, - showPageSizeOptions = true, - } = paginationConfig; + const previousDataRef = useRef(paginationResult.data); + if (paginationResult.data) { + previousDataRef.current = paginationResult.data; + } - // Determine if we're in controlled mode - const isControlled = - controlledOffset !== undefined || controlledPageSize !== undefined; + const displayData = paginationResult.data ?? previousDataRef.current; + const isStale = paginationResult.loading && displayData !== undefined; - // Use providedRowCount if passed, otherwise fallback to data length - const rowCount = providedRowCount ?? data?.length ?? 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( + const pagination = useMemo( () => ({ - offset: currentOffset, - pageSize: currentPageSize, - rowCount, - setOffset, - setPageSize, - onNextPage, - onPreviousPage, + type: 'page' as const, + pageSize: paginationResult.pageSize, + offset: paginationResult.offset, + totalCount: paginationResult.totalCount, + hasNextPage: paginationResult.hasNextPage, + hasPreviousPage: paginationResult.hasPreviousPage, + onNextPage: paginationResult.onNextPage, + onPreviousPage: paginationResult.onPreviousPage, + onPageSizeChange: paginationResult.onPageSizeChange, showPageSizeOptions, + getLabel, }), [ - currentOffset, - currentPageSize, - rowCount, - setOffset, - setPageSize, - onNextPage, - onPreviousPage, - showPageSizeOptions, + paginationResult.pageSize, + paginationResult.offset, + paginationResult.totalCount, + paginationResult.hasNextPage, + paginationResult.hasPreviousPage, + paginationResult.onNextPage, + paginationResult.onPreviousPage, + paginationResult.onPageSizeChange, ], ); - const pagination: UseTablePagination = useMemo( + return useMemo( () => ({ - paginationProps, - offset: currentOffset, - pageSize: currentPageSize, - data: currentData, - nextPage, - previousPage, - setOffset, - setPageSize, + data: displayData, + loading: paginationResult.loading, + isStale, + error: paginationResult.error, + pagination, + sort: sortState, }), [ - paginationProps, - currentOffset, - currentPageSize, - currentData, - nextPage, - previousPage, - setOffset, - setPageSize, + displayData, + paginationResult.loading, + isStale, + paginationResult.error, + pagination, + showPageSizeOptions, + getLabel, + sortState, ], ); +} + +/** @public */ +export function useTable( + options: UseTableOptions, +): UseTableResult { + const query = useQueryState(options); + + let pagination: PaginationResult & { reload: () => void }; + + // Conditional hooks - mode is stable for lifetime of component + if (options.mode === 'complete') { + pagination = useCompletePagination(options, query); + } else if (options.mode === 'offset') { + pagination = useOffsetPagination(options, query); + } else if (options.mode === 'cursor') { + pagination = useCursorPagination(options, query); + } else { + throw new Error('Invalid mode'); + } + + const sortState: SortState = useMemo( + () => ({ descriptor: query.sort, onSortChange: query.setSort }), + [query.sort, query.setSort], + ); + + const tableProps = useTableProps( + pagination, + sortState, + options.paginationOptions ?? {}, + ); return { - data: currentData, - paginationProps, - pagination, + tableProps, + reload: pagination.reload, + filter: { value: query.filter, onFilterChange: query.setFilter }, + search: { value: query.search, onSearchChange: query.setSearch }, }; } diff --git a/packages/ui/src/components/Table/index.ts b/packages/ui/src/components/Table/index.ts index 42ba6ff9c9..0d3d5ff8eb 100644 --- a/packages/ui/src/components/Table/index.ts +++ b/packages/ui/src/components/Table/index.ts @@ -15,6 +15,7 @@ */ export { Table } from './components/Table'; +export { TableRoot } from './components/TableRoot'; export { TableHeader } from './components/TableHeader'; export { TableBody } from './components/TableBody'; export { Column } from './components/Column'; @@ -29,12 +30,33 @@ export type { CellTextProps, CellProfileProps, ColumnProps, + TableProps, + TableRootProps, + TableItem, + ColumnConfig, + RowConfig, + RowRenderFn, + TableSelection, + SortState, + SortDescriptor, + NoPagination, + PagePagination, + TablePaginationType, } from './types'; export type { - UseTableConfig, + UseTableOptions, UseTableResult, - UseTablePagination, - UseTablePaginationConfig, + UseTableCompleteOptions, + UseTableOffsetOptions, + UseTableCursorOptions, + OffsetParams, + OffsetResponse, + CursorParams, + CursorResponse, + FilterState, + SearchState, + QueryOptions, + PaginationOptions, } from './hooks/types'; export { TableDefinition } from './definition'; diff --git a/packages/ui/src/components/Table/stories/Table.dev.stories.tsx b/packages/ui/src/components/Table/stories/Table.dev.stories.tsx new file mode 100644 index 0000000000..5be055cd01 --- /dev/null +++ b/packages/ui/src/components/Table/stories/Table.dev.stories.tsx @@ -0,0 +1,965 @@ +/* eslint-disable no-restricted-syntax */ +/* + * 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, Fragment } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Table, + TableRoot, + TableHeader, + TableBody, + Column, + Row, + CellText, + CellProfile, + useTable, + type ColumnConfig, +} from '..'; +import { Button } from '../../Button'; +import { TextField } from '../../TextField'; +import { Select } from '../../Select'; +import { Flex } from '../../Flex'; +import { data as data1 } from './mocked-data1'; +import { data as data4 } from './mocked-data4'; +import { selectionData, selectionColumns, tableStoriesMeta } from './utils'; + +const meta = { + title: 'Backstage UI/Table/dev', + ...tableStoriesMeta, +} satisfies Meta; + +export default meta; +type Story = StoryObj; +type Data1Item = (typeof data1)[0]; +type Data4Item = (typeof data4)[0]; + +export const BasicLocalData: 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 => , + }, + { + id: 'lifecycle', + label: 'Lifecycle', + cell: item => , + }, + ]; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => data1, + paginationOptions: { pageSize: 5 }, + }); + + return ; + }, +}; + +export const Sorting: Story = { + render: () => { + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => , + isSortable: true, + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + isSortable: true, + }, + { + id: 'type', + label: 'Type', + cell: item => , + isSortable: true, + }, + { + id: 'lifecycle', + label: 'Lifecycle', + cell: item => , + isSortable: true, + }, + ]; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => data1, + paginationOptions: { pageSize: 5 }, + initialSort: { column: 'name', direction: 'ascending' }, + sortFn: (items, { column, direction }) => { + return [...items].sort((a, b) => { + let aVal: string; + let bVal: string; + if (column === 'name') { + aVal = a.name; + bVal = b.name; + } else if (column === 'owner') { + aVal = a.owner.name; + bVal = b.owner.name; + } else if (column === 'type') { + aVal = a.type; + bVal = b.type; + } else { + aVal = a.lifecycle; + bVal = b.lifecycle; + } + const cmp = aVal.localeCompare(bVal); + return direction === 'descending' ? -cmp : cmp; + }); + }, + }); + + return
; + }, +}; + +export const Search: Story = { + render: () => { + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => , + isSortable: true, + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + { + id: 'type', + label: 'Type', + cell: item => , + }, + ]; + + const { tableProps, search } = useTable({ + mode: 'complete', + getData: () => data1, + paginationOptions: { pageSize: 5 }, + searchFn: (items, query) => { + const lowerQuery = query.toLowerCase(); + return items.filter( + item => + item.name.toLowerCase().includes(lowerQuery) || + item.owner.name.toLowerCase().includes(lowerQuery) || + item.type.toLowerCase().includes(lowerQuery), + ); + }, + }); + + return ( +
+ search.onSearchChange(value)} + style={{ marginBottom: '16px' }} + /> +
No results found + ) : ( +
No data available
+ ) + } + {...tableProps} + /> + + ); + }, +}; + +export const Selection: Story = { + render: () => { + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + 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: 5 }, + }); + + return ( +
+ ); + }, +}; + +export const RowLinks: Story = { + render: () => { + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Band name', + isRowHeader: true, + cell: item => , + }, + { + id: 'genre', + label: 'Genre', + cell: item => , + }, + { + id: 'yearFormed', + label: 'Year formed', + cell: item => , + }, + ]; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => data4, + paginationOptions: { pageSize: 5 }, + }); + + return ( +
`/bands/${item.id}` }} + /> + ); + }, +}; + +export const Reload: Story = { + render: () => { + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => , + }, + { + id: 'type', + label: 'Type', + cell: item => , + }, + ]; + + const { tableProps, reload } = useTable({ + mode: 'complete', + getData: () => data1, + paginationOptions: { pageSize: 5 }, + }); + + return ( +
+ +
+ + ); + }, +}; + +export const ServerSidePaginationOffset: 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: 'offset', + getData: async ({ offset, pageSize }) => { + await new Promise(resolve => setTimeout(resolve, 500)); + return { + data: data1.slice(offset, offset + pageSize), + totalCount: data1.length, + }; + }, + paginationOptions: { pageSize: 5 }, + }); + + return
; + }, +}; + +export const ServerSidePaginationCursor: Story = { + render: () => { + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Band name', + isRowHeader: true, + cell: item => , + }, + { + id: 'genre', + label: 'Genre', + cell: item => , + }, + ]; + + const { tableProps } = useTable({ + mode: 'cursor', + getData: async ({ cursor, pageSize }) => { + await new Promise(resolve => setTimeout(resolve, 500)); + const startIndex = cursor ? parseInt(cursor, 10) : 0; + const nextIndex = startIndex + pageSize; + return { + data: data4.slice(startIndex, nextIndex), + totalCount: data4.length, + nextCursor: nextIndex < data4.length ? String(nextIndex) : undefined, + prevCursor: + startIndex > 0 + ? String(Math.max(0, startIndex - pageSize)) + : undefined, + }; + }, + paginationOptions: { pageSize: 5 }, + }); + + return
; + }, +}; + +export const CustomRowRender: Story = { + render: () => { + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => , + }, + { + id: 'type', + label: 'Type', + cell: item => , + }, + { + id: 'lifecycle', + label: 'Lifecycle', + cell: item => , + }, + ]; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => data1, + paginationOptions: { pageSize: 5 }, + }); + + return ( +
( + + {column => ( + + {column.id === 'name' ? ( + + ) : ( + column.cell(item) + )} + + )} + + )} + /> + ); + }, +}; + +export const AtomicComponents: Story = { + render: () => { + const displayData = data1.slice(0, 5); + + return ( + + + Name + Owner + Type + + + {displayData.map(item => ( + + + + + + ))} + + + ); + }, +}; + +export const RowClick: Story = { + render: () => { + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Band name', + isRowHeader: true, + cell: item => ( + + ), + }, + { + id: 'genre', + label: 'Genre', + cell: item => , + }, + { + id: 'yearFormed', + label: 'Year formed', + cell: item => , + }, + { + id: 'albums', + label: 'Albums', + cell: item => , + }, + ]; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => data4, + paginationOptions: { pageSize: 5 }, + }); + + return ( +
alert(`Clicked: ${item.name}`) }} + /> + ); + }, +}; + +export const SelectionSingleToggle: Story = { + render: () => { + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => selectionData, + paginationOptions: { pageSize: 10 }, + }); + + return ( +
+ ); + }, +}; + +export const SelectionMultiToggle: Story = { + render: () => { + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => selectionData, + paginationOptions: { pageSize: 10 }, + }); + + return ( +
+ ); + }, +}; + +export const SelectionWithRowClick: Story = { + render: () => { + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => selectionData, + paginationOptions: { pageSize: 10 }, + }); + + return ( +
alert(`Clicked: ${item.name}`) }} + /> + ); + }, +}; + +export const SelectionWithRowLinks: Story = { + render: () => { + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => selectionData, + paginationOptions: { pageSize: 10 }, + }); + + return ( +
`/items/${item.id}` }} + /> + ); + }, +}; + +export const SelectionWithPagination: Story = { + render: () => { + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => data1, + paginationOptions: { pageSize: 5 }, + }); + + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => , + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + { + id: 'type', + label: 'Type', + cell: item => , + }, + ]; + + return ( +
+ ); + }, +}; + +export const SelectionSingleReplace: Story = { + render: () => { + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => selectionData, + paginationOptions: { pageSize: 10 }, + }); + + return ( +
+ ); + }, +}; + +export const SelectionMultiReplace: Story = { + render: () => { + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => selectionData, + paginationOptions: { pageSize: 10 }, + }); + + return ( +
+ ); + }, +}; + +export const SelectionReplaceWithRowClick: Story = { + render: () => { + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => selectionData, + paginationOptions: { pageSize: 10 }, + }); + + return ( +
alert(`Opening ${item.name}`) }} + /> + ); + }, +}; + +export const SelectionReplaceWithRowLinks: Story = { + render: () => { + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => selectionData, + paginationOptions: { pageSize: 10 }, + }); + + return ( +
`/items/${item.id}` }} + /> + ); + }, +}; + +// Type filter interface for ComprehensiveServerSide story +interface TypeFilter { + type: string | null; +} + +/** + * Comprehensive example showcasing a common complex use case: + * - Server-side offset pagination + * - Search/filtering + * - Sorting + * - Multi-selection + * - Type filter dropdown + */ +export const ComprehensiveServerSide: Story = { + render: () => { + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const typeOptions = [ + { value: '', label: 'All types' }, + { value: 'service', label: 'Service' }, + { value: 'website', label: 'Website' }, + { value: 'library', label: 'Library' }, + { value: 'documentation', label: 'Documentation' }, + { value: 'other', label: 'Other' }, + ]; + + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + isSortable: true, + cell: item => ( + + ), + }, + { + id: 'owner', + label: 'Owner', + isSortable: true, + cell: item => , + }, + { + id: 'type', + label: 'Type', + isSortable: true, + cell: item => , + }, + { + id: 'lifecycle', + label: 'Lifecycle', + isSortable: true, + cell: item => , + }, + ]; + + const { tableProps, search, filter } = useTable({ + mode: 'offset', + initialSort: { column: 'name', direction: 'ascending' }, + getData: async ({ + offset, + pageSize, + sort, + filter: typeFilter, + search: searchQuery, + }) => { + // Simulate server-side filtering, sorting, and pagination + // with slower and slower responses + const page = Math.floor(offset / pageSize) + 1; + await new Promise(resolve => setTimeout(resolve, 300 * page)); + + let filtered = [...data1]; + + // Apply search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter( + item => + item.name.toLowerCase().includes(query) || + item.owner.name.toLowerCase().includes(query) || + item.description?.toLowerCase().includes(query), + ); + } + + // Apply type filter + if (typeFilter?.type) { + filtered = filtered.filter(item => item.type === typeFilter.type); + } + + // Apply sorting + if (sort) { + filtered.sort((a, b) => { + let aVal: string; + let bVal: string; + switch (sort.column) { + case 'owner': + aVal = a.owner.name; + bVal = b.owner.name; + break; + case 'type': + aVal = a.type; + bVal = b.type; + break; + case 'lifecycle': + aVal = a.lifecycle; + bVal = b.lifecycle; + break; + default: + aVal = a.name; + bVal = b.name; + } + const cmp = aVal.localeCompare(bVal); + return sort.direction === 'descending' ? -cmp : cmp; + }); + } + + return { + data: filtered.slice(offset, offset + pageSize), + totalCount: filtered.length, + }; + }, + paginationOptions: { pageSize: 10 }, + }); + + return ( + + + +
No results match your filters + ) : ( +
No data available
+ ) + } + /> + + ); + }, +}; diff --git a/packages/ui/src/components/Table/stories/Table.docs.stories.tsx b/packages/ui/src/components/Table/stories/Table.docs.stories.tsx new file mode 100644 index 0000000000..49e0b44885 --- /dev/null +++ b/packages/ui/src/components/Table/stories/Table.docs.stories.tsx @@ -0,0 +1,198 @@ +/* eslint-disable no-restricted-syntax */ +/* + * 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 } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Table, CellText, CellProfile, useTable, type ColumnConfig } from '..'; +import { Flex } from '../../Flex'; +import { Text } from '../../Text'; +import { RadioGroup, Radio } from '../../RadioGroup'; +import { data as data4 } from './mocked-data4'; +import { selectionData, selectionColumns, tableStoriesMeta } from './utils'; + +const meta = { + title: 'Backstage UI/Table/docs', + ...tableStoriesMeta, +} satisfies Meta; + +export default meta; +type Story = StoryObj; +type Data4Item = (typeof data4)[0]; + +export const TableRockBand: Story = { + render: () => { + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Band name', + isRowHeader: true, + cell: item => ( + + ), + }, + { + id: 'genre', + label: 'Genre', + cell: item => , + }, + { + id: 'yearFormed', + label: 'Year formed', + cell: item => , + }, + { + id: 'albums', + label: 'Albums', + cell: item => , + }, + ]; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => data4, + paginationOptions: { pageSize: 5 }, + }); + + return
; + }, +}; + +export const SelectionToggleWithActions: Story = { + render: () => { + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => selectionData, + paginationOptions: { pageSize: 10 }, + }); + + return ( +
alert(`Clicked: ${item.name}`) }} + /> + ); + }, +}; + +export const SelectionModePlayground: Story = { + render: () => { + const [selectionMode, setSelectionMode] = useState<'single' | 'multiple'>( + 'multiple', + ); + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => selectionData, + paginationOptions: { pageSize: 10 }, + }); + + return ( + +
+
+ + Selection mode: + + { + setSelectionMode(value as 'single' | 'multiple'); + setSelected(new Set()); + }} + > + single + multiple + +
+ + ); + }, +}; + +export const SelectionBehaviorPlayground: Story = { + render: () => { + const [selectionBehavior, setSelectionBehavior] = useState< + 'toggle' | 'replace' + >('toggle'); + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => selectionData, + paginationOptions: { pageSize: 10 }, + }); + + return ( + +
+
+ + Selection behavior: + + { + setSelectionBehavior(value as 'toggle' | 'replace'); + setSelected(new Set()); + }} + > + toggle + replace + +
+ + ); + }, +}; diff --git a/packages/ui/src/components/Table/stories/Table.visual.stories.tsx b/packages/ui/src/components/Table/stories/Table.visual.stories.tsx new file mode 100644 index 0000000000..34259dde96 --- /dev/null +++ b/packages/ui/src/components/Table/stories/Table.visual.stories.tsx @@ -0,0 +1,340 @@ +/* eslint-disable no-restricted-syntax */ +/* + * 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 } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Table, CellText, CellProfile, useTable, type ColumnConfig } from '..'; +import { data as data1 } from './mocked-data1'; +import { data as data4 } from './mocked-data4'; +import { selectionData, selectionColumns, tableStoriesMeta } from './utils'; + +const meta = { + title: 'Backstage UI/Table/visual', + ...tableStoriesMeta, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +type Data1Item = (typeof data1)[0]; +type Data4Item = (typeof data4)[0]; +type CellTextVariantsItem = (typeof cellTextVariantsData)[0]; + +export const ProfileCells: Story = { + render: () => { + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Band name', + isRowHeader: true, + cell: item => ( + + ), + }, + { + id: 'genre', + label: 'Genre', + cell: item => , + }, + { + id: 'yearFormed', + label: 'Year formed', + cell: item => , + }, + { + id: 'albums', + label: 'Albums', + cell: item => , + }, + ]; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => data4, + paginationOptions: { pageSize: 5 }, + }); + + return
; + }, +}; + +export const EmptyState: Story = { + render: () => { + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => , + }, + { + id: 'type', + label: 'Type', + cell: item => , + }, + ]; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => [], + paginationOptions: { pageSize: 5 }, + }); + + return ( +
No data available} + /> + ); + }, +}; + +export const NoPagination: 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 => , + }, + ]; + + return ( +
+ ); + }, +}; + +export const SelectionWithDisabledRows: Story = { + render: () => { + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => selectionData, + paginationOptions: { pageSize: 10 }, + }); + + return ( +
item.id === 2, + }} + /> + ); + }, +}; + +// Data for CellTextVariants story showcasing multiple features +const cellTextVariantsData = [ + { + id: 1, + name: 'Authentication Service', + description: 'Handles user login and session management', + type: 'service', + owner: 'Platform Team', + }, + { + id: 2, + name: 'A very long component name that should be truncated when it exceeds the available column width', + description: + 'This is also a very long description that demonstrates text truncation behavior in the table cells', + type: 'library', + owner: 'Frontend Team', + }, + { + id: 3, + name: 'API Gateway', + description: 'Routes and validates API requests', + type: 'service', + owner: 'Backend Team', + }, +]; + +export const CellTextVariants: Story = { + render: () => { + const [selected, setSelected] = useState | 'all'>( + new Set(['1', '3']), + ); + const [sortDescriptor, setSortDescriptor] = useState<{ + column: string; + direction: 'ascending' | 'descending'; + }>({ column: 'name', direction: 'ascending' }); + + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + isSortable: true, + cell: item => ( + + ), + }, + { + id: 'type', + label: 'Type', + isSortable: true, + cell: item => ( + 📦} + /> + ), + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + ]; + + return ( +
+ setSortDescriptor({ + column: String(descriptor.column), + direction: descriptor.direction, + }), + }} + /> + ); + }, +}; + +export const LoadingState: Story = { + render: () => { + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => , + }, + { + id: 'type', + label: 'Type', + cell: item => , + }, + ]; + + return ( +
+ ); + }, +}; + +export const ErrorState: Story = { + render: () => { + const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => , + }, + { + id: 'type', + label: 'Type', + cell: item => , + }, + ]; + + return ( +
+ ); + }, +}; + +export const StaleState: 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 => , + }, + ]; + + return ( +
+ ); + }, +}; diff --git a/packages/ui/src/components/Table/mocked-data1.ts b/packages/ui/src/components/Table/stories/mocked-data1.ts similarity index 93% rename from packages/ui/src/components/Table/mocked-data1.ts rename to packages/ui/src/components/Table/stories/mocked-data1.ts index ea5a9f2db8..b508bce387 100644 --- a/packages/ui/src/components/Table/mocked-data1.ts +++ b/packages/ui/src/components/Table/stories/mocked-data1.ts @@ -15,6 +15,7 @@ */ export interface DataProps { + id: string; name: string; owner: { name: string; @@ -29,6 +30,7 @@ export interface DataProps { export const data: DataProps[] = [ { + id: 'authentication-and-authorization-service', name: 'authentication-and-authorization-service', owner: { name: 'security-team', @@ -42,6 +44,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'user-interface-dashboard-and-analytics-platform', name: 'user-interface-dashboard-and-analytics-platform', owner: { name: 'frontend-team', @@ -55,6 +58,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'payment-gateway', name: 'payment-gateway', owner: { name: 'finance-team', @@ -68,6 +72,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'real-time-analytics-processing-and-visualization-engine', name: 'real-time-analytics-processing-and-visualization-engine', owner: { name: 'data-team', @@ -81,6 +86,7 @@ export const data: DataProps[] = [ lifecycle: 'experimental', }, { + id: 'notification-center', name: 'notification-center', owner: { name: 'platform-team', @@ -94,6 +100,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'administrative-control-panel-and-user-management-interface', name: 'administrative-control-panel-and-user-management-interface', owner: { name: 'frontend-team', @@ -107,6 +114,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'search-indexer', name: 'search-indexer', owner: { name: 'search-team', @@ -120,6 +128,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'cross-platform-mobile-application-framework', name: 'cross-platform-mobile-application-framework', owner: { name: 'mobile-team', @@ -133,6 +142,7 @@ export const data: DataProps[] = [ lifecycle: 'experimental', }, { + id: 'database-migration', name: 'database-migration', owner: { name: 'devops-team', @@ -146,6 +156,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'api-gateway', name: 'api-gateway', owner: { name: 'platform-team', @@ -159,6 +170,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'content-management', name: 'content-management', owner: { name: 'content-team', @@ -172,6 +184,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'enterprise-reporting-and-analytics-dashboard', name: 'enterprise-reporting-and-analytics-dashboard', owner: { name: 'analytics-team', @@ -185,6 +198,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'image-processing-and-optimization-service', name: 'image-processing-and-optimization-service', owner: { name: 'media-team', @@ -198,6 +212,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'customer-portal', name: 'customer-portal', owner: { name: 'frontend-team', @@ -211,6 +226,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'log-aggregator', name: 'log-aggregator', owner: { name: 'devops-team', @@ -224,6 +240,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'identity-provider', name: 'identity-provider', owner: { name: 'security-team', @@ -237,6 +254,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'document-storage', name: 'document-storage', owner: { name: 'storage-team', @@ -250,6 +268,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'workflow-engine', name: 'workflow-engine', owner: { name: 'platform-team', @@ -263,6 +282,7 @@ export const data: DataProps[] = [ lifecycle: 'experimental', }, { + id: 'mobile-backend', name: 'mobile-backend', owner: { name: 'mobile-team', @@ -276,6 +296,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'system-monitoring-and-alerting-dashboard', name: 'system-monitoring-and-alerting-dashboard', owner: { name: 'devops-team', @@ -289,6 +310,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'email-service', name: 'email-service', owner: { name: 'communication-team', @@ -302,6 +324,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'data-pipeline', name: 'data-pipeline', owner: { name: 'data-team', @@ -315,6 +338,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'configuration-manager', name: 'configuration-manager', owner: { name: 'platform-team', @@ -328,6 +352,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'testing-framework', name: 'testing-framework', owner: { name: 'qa-team', @@ -341,6 +366,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'cache-service', name: 'cache-service', owner: { name: 'platform-team', @@ -354,6 +380,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'billing-system', name: 'billing-system', owner: { name: 'finance-team', @@ -367,6 +394,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'comprehensive-product-documentation-and-api-reference', name: 'comprehensive-product-documentation-and-api-reference', owner: { name: 'docs-team', @@ -380,6 +408,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'queue-manager', name: 'queue-manager', owner: { name: 'platform-team', @@ -393,6 +422,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'security-scanner', name: 'security-scanner', owner: { name: 'security-team', @@ -406,6 +436,7 @@ export const data: DataProps[] = [ lifecycle: 'experimental', }, { + id: 'user-profile', name: 'user-profile', owner: { name: 'frontend-team', @@ -419,6 +450,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'data-warehouse', name: 'data-warehouse', owner: { name: 'data-team', @@ -432,6 +464,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'deployment-automation', name: 'deployment-automation', owner: { name: 'devops-team', @@ -445,6 +478,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'chat-service', name: 'chat-service', owner: { name: 'communication-team', @@ -458,6 +492,7 @@ export const data: DataProps[] = [ lifecycle: 'experimental', }, { + id: 'analytics-dashboard', name: 'analytics-dashboard', owner: { name: 'analytics-team', @@ -471,6 +506,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'file-uploader', name: 'file-uploader', owner: { name: 'storage-team', @@ -484,6 +520,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'search-service', name: 'search-service', owner: { name: 'search-team', @@ -497,6 +534,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'mobile-sdk', name: 'mobile-sdk', owner: { name: 'mobile-team', @@ -510,6 +548,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'performance-monitor', name: 'performance-monitor', owner: { name: 'devops-team', @@ -523,6 +562,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'content-delivery', name: 'content-delivery', owner: { name: 'media-team', @@ -536,6 +576,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'user-authentication', name: 'user-authentication', owner: { name: 'security-team', @@ -549,6 +590,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'data-export', name: 'data-export', owner: { name: 'data-team', @@ -562,6 +604,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'admin-api', name: 'admin-api', owner: { name: 'platform-team', @@ -575,6 +618,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'testing-dashboard', name: 'testing-dashboard', owner: { name: 'qa-team', @@ -587,6 +631,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'message-broker', name: 'message-broker', owner: { name: 'platform-team', @@ -600,6 +645,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'payment-processor', name: 'payment-processor', owner: { name: 'finance-team', @@ -613,6 +659,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'document-viewer', name: 'document-viewer', owner: { name: 'frontend-team', @@ -625,6 +672,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'load-balancer', name: 'load-balancer', owner: { name: 'devops-team', @@ -638,6 +686,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'security-audit', name: 'security-audit', owner: { name: 'security-team', @@ -651,6 +700,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'user-settings', name: 'user-settings', owner: { name: 'frontend-team', @@ -664,6 +714,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'data-import', name: 'data-import', owner: { name: 'data-team', @@ -677,6 +728,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'infrastructure-monitor', name: 'infrastructure-monitor', owner: { name: 'devops-team', @@ -690,6 +742,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'notification-manager', name: 'notification-manager', owner: { name: 'communication-team', @@ -703,6 +756,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'analytics-processor', name: 'analytics-processor', owner: { name: 'analytics-team', @@ -716,6 +770,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'file-manager', name: 'file-manager', owner: { name: 'storage-team', @@ -728,6 +783,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'search-index', name: 'search-index', owner: { name: 'search-team', @@ -740,6 +796,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'mobile-authentication', name: 'mobile-authentication', owner: { name: 'mobile-team', @@ -753,6 +810,7 @@ export const data: DataProps[] = [ lifecycle: 'experimental', }, { + id: 'system-monitor', name: 'system-monitor', owner: { name: 'devops-team', @@ -766,6 +824,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'media-processor', name: 'media-processor', owner: { name: 'media-team', @@ -778,6 +837,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'user-management', name: 'user-management', owner: { name: 'security-team', @@ -790,6 +850,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'data-transformer', name: 'data-transformer', owner: { name: 'data-team', @@ -803,6 +864,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'admin-dashboard', name: 'admin-dashboard', owner: { name: 'platform-team', @@ -816,6 +878,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'test-automation', name: 'test-automation', owner: { name: 'qa-team', @@ -828,6 +891,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'event-bus', name: 'event-bus', owner: { name: 'platform-team', @@ -840,6 +904,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'invoice-generator', name: 'invoice-generator', owner: { name: 'finance-team', @@ -852,6 +917,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'document-editor', name: 'document-editor', owner: { name: 'frontend-team', @@ -864,6 +930,7 @@ export const data: DataProps[] = [ lifecycle: 'experimental', }, { + id: 'service-discovery', name: 'service-discovery', owner: { name: 'devops-team', @@ -876,6 +943,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'security-monitor', name: 'security-monitor', owner: { name: 'security-team', @@ -888,6 +956,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'user-preferences', name: 'user-preferences', owner: { name: 'frontend-team', @@ -900,6 +969,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'data-validator', name: 'data-validator', owner: { name: 'data-team', @@ -912,6 +982,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'infrastructure-automation', name: 'infrastructure-automation', owner: { name: 'devops-team', @@ -925,6 +996,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'notification-dispatcher', name: 'notification-dispatcher', owner: { name: 'communication-team', @@ -938,6 +1010,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'analytics-collector', name: 'analytics-collector', owner: { name: 'analytics-team', @@ -950,6 +1023,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'file-processor', name: 'file-processor', owner: { name: 'storage-team', @@ -962,6 +1036,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'search-analyzer', name: 'search-analyzer', owner: { name: 'search-team', @@ -974,6 +1049,7 @@ export const data: DataProps[] = [ lifecycle: 'experimental', }, { + id: 'mobile-notifications', name: 'mobile-notifications', owner: { name: 'mobile-team', @@ -986,6 +1062,7 @@ export const data: DataProps[] = [ lifecycle: 'experimental', }, { + id: 'system-alerts', name: 'system-alerts', owner: { name: 'devops-team', @@ -998,6 +1075,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'media-encoder', name: 'media-encoder', owner: { name: 'media-team', @@ -1010,6 +1088,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'user-authorization', name: 'user-authorization', owner: { name: 'security-team', @@ -1022,6 +1101,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'data-aggregator', name: 'data-aggregator', owner: { name: 'data-team', @@ -1034,6 +1114,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'admin-authentication', name: 'admin-authentication', owner: { name: 'platform-team', @@ -1046,6 +1127,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'test-coverage', name: 'test-coverage', owner: { name: 'qa-team', @@ -1058,6 +1140,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'event-processor', name: 'event-processor', owner: { name: 'platform-team', @@ -1070,6 +1153,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'payment-validator', name: 'payment-validator', owner: { name: 'finance-team', @@ -1082,6 +1166,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'document-converter', name: 'document-converter', owner: { name: 'frontend-team', @@ -1094,6 +1179,7 @@ export const data: DataProps[] = [ lifecycle: 'experimental', }, { + id: 'service-health', name: 'service-health', owner: { name: 'devops-team', @@ -1106,6 +1192,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'security-logger', name: 'security-logger', owner: { name: 'security-team', @@ -1118,6 +1205,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'user-analytics', name: 'user-analytics', owner: { name: 'frontend-team', @@ -1131,6 +1219,7 @@ export const data: DataProps[] = [ lifecycle: 'experimental', }, { + id: 'data-cleaner', name: 'data-cleaner', owner: { name: 'data-team', @@ -1143,6 +1232,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'infrastructure-deployer', name: 'infrastructure-deployer', owner: { name: 'devops-team', @@ -1155,6 +1245,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'notification-queue', name: 'notification-queue', owner: { name: 'communication-team', @@ -1167,6 +1258,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'analytics-exporter', name: 'analytics-exporter', owner: { name: 'analytics-team', @@ -1179,6 +1271,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'file-validator', name: 'file-validator', owner: { name: 'storage-team', @@ -1191,6 +1284,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'search-optimizer', name: 'search-optimizer', owner: { name: 'search-team', @@ -1203,6 +1297,7 @@ export const data: DataProps[] = [ lifecycle: 'experimental', }, { + id: 'mobile-analytics', name: 'mobile-analytics', owner: { name: 'mobile-team', @@ -1215,6 +1310,7 @@ export const data: DataProps[] = [ lifecycle: 'experimental', }, { + id: 'system-logger', name: 'system-logger', owner: { name: 'devops-team', @@ -1227,6 +1323,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'media-validator', name: 'media-validator', owner: { name: 'media-team', @@ -1239,6 +1336,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'user-audit', name: 'user-audit', owner: { name: 'security-team', @@ -1251,6 +1349,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'data-normalizer', name: 'data-normalizer', owner: { name: 'data-team', @@ -1263,6 +1362,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'admin-authorization', name: 'admin-authorization', owner: { name: 'platform-team', @@ -1275,6 +1375,7 @@ export const data: DataProps[] = [ lifecycle: 'production', }, { + id: 'test-reporting', name: 'test-reporting', owner: { name: 'qa-team', diff --git a/packages/ui/src/components/Table/mocked-data2.ts b/packages/ui/src/components/Table/stories/mocked-data2.ts similarity index 100% rename from packages/ui/src/components/Table/mocked-data2.ts rename to packages/ui/src/components/Table/stories/mocked-data2.ts diff --git a/packages/ui/src/components/Table/mocked-data3.ts b/packages/ui/src/components/Table/stories/mocked-data3.ts similarity index 100% rename from packages/ui/src/components/Table/mocked-data3.ts rename to packages/ui/src/components/Table/stories/mocked-data3.ts diff --git a/packages/ui/src/components/Table/mocked-data4.ts b/packages/ui/src/components/Table/stories/mocked-data4.ts similarity index 99% rename from packages/ui/src/components/Table/mocked-data4.ts rename to packages/ui/src/components/Table/stories/mocked-data4.ts index b86d4c903f..5cedd2cbae 100644 --- a/packages/ui/src/components/Table/mocked-data4.ts +++ b/packages/ui/src/components/Table/stories/mocked-data4.ts @@ -15,6 +15,7 @@ */ export interface RockBandDataProps { + id: string; name: string; image: string; description: string; @@ -31,6 +32,7 @@ export interface RockBandDataProps { export const data: RockBandDataProps[] = [ { + id: 'The Beatles', name: 'The Beatles', image: 'https://upload.wikimedia.org/wikipedia/en/thumb/4/42/Beatles_-_Abbey_Road.jpg/250px-Beatles_-_Abbey_Road.jpg', @@ -57,6 +59,7 @@ export const data: RockBandDataProps[] = [ website: 'https://www.thebeatles.com', }, { + id: 'Led Zeppelin', name: 'Led Zeppelin', image: 'data:image/jpeg;base64,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', @@ -74,6 +77,7 @@ export const data: RockBandDataProps[] = [ website: 'https://www.ledzeppelin.com', }, { + id: 'Pink Floyd', name: 'Pink Floyd', image: 'https://upload.wikimedia.org/wikipedia/en/d/d6/Pink_Floyd_-_all_members.jpg', @@ -97,6 +101,7 @@ export const data: RockBandDataProps[] = [ website: 'https://www.pinkfloyd.com', }, { + id: 'The Rolling Stones', name: 'The Rolling Stones', image: 'data:image/jpeg;base64,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', @@ -119,6 +124,7 @@ export const data: RockBandDataProps[] = [ website: 'https://www.rollingstones.com', }, { + id: 'Queen', name: 'Queen', image: 'https://i.scdn.co/image/af2b8e57f6d7b5d43a616bd1e27ba552cd8bfd42', description: @@ -134,6 +140,7 @@ export const data: RockBandDataProps[] = [ website: 'https://www.queenonline.com', }, { + id: 'The Who', name: 'The Who', image: 'https://encrypted-tbn3.gstatic.com/images?q=tbn:ANd9GcQoKRCDsHx1DyrnXBxOgpPUHZZIwebCq91Ge8oQylUJXlWzlOwtzQtepNBzfbT_497Ymm1sU1Oq2OV0qGmxa6igziOxHJX4Eo8oyk088RpF', @@ -155,6 +162,7 @@ export const data: RockBandDataProps[] = [ website: 'https://www.thewho.com', }, { + id: 'Black Sabbath', name: 'Black Sabbath', image: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQ8qFdD0l8GNdMw30NkViSV7BNfUab-7wZXWs1jC3qYIt6llh-WVpJ9MLmgv4QKo9Gq-FOOYJ3-aOr9jQjDhcToYWj2VO9LbBIh7MO6e34PNw', @@ -171,6 +179,7 @@ export const data: RockBandDataProps[] = [ website: 'https://www.blacksabbath.com', }, { + id: 'The Doors', name: 'The Doors', image: 'https://encrypted-tbn2.gstatic.com/images?q=tbn:ANd9GcQVPqssHHK-u-EaYkVo8YuiCBINkJYVsAkqIlhojm2swhdc1Xn9lWkzokObi1_lAcr1CZ_dEbKnBTD9hfkUcPJ9vrwg-ARlgurH2rA5I4iwXQ', @@ -188,6 +197,7 @@ export const data: RockBandDataProps[] = [ website: 'https://www.thedoors.com', }, { + id: 'Jimi Hendrix Experience', name: 'Jimi Hendrix Experience', image: 'https://encrypted-tbn3.gstatic.com/images?q=tbn:ANd9GcQkDuNxy0xKisGP4lUk7N0QqrOQpyHNy9U2lpQhKBH5BqnRn6drBm8HYWVwcFfea1brVTxs7jSSVH1hjyrwTlzGRZCgfC91C-YgEStSzuvMwg', @@ -205,6 +215,7 @@ export const data: RockBandDataProps[] = [ website: 'https://www.jimihendrix.com', }, { + id: 'Cream', name: 'Cream', image: 'data:image/jpeg;base64,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', diff --git a/packages/ui/src/components/Table/stories/utils.tsx b/packages/ui/src/components/Table/stories/utils.tsx new file mode 100644 index 0000000000..45bc6341ad --- /dev/null +++ b/packages/ui/src/components/Table/stories/utils.tsx @@ -0,0 +1,54 @@ +/* eslint-disable no-restricted-syntax */ +/* + * 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 { Meta } from '@storybook/react-vite'; +import { MemoryRouter } from 'react-router-dom'; +import { CellText, type ColumnConfig } from '..'; + +// Selection demo data +export const selectionData = [ + { id: 1, name: 'Component Library', owner: 'Design System', type: 'library' }, + { id: 2, name: 'API Gateway', owner: 'Platform', type: 'service' }, + { id: 3, name: 'Documentation Site', owner: 'DevEx', type: 'website' }, +]; + +// Selection demo columns +export const selectionColumns: ColumnConfig<(typeof selectionData)[0]>[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => , + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + { id: 'type', label: 'Type', cell: item => }, +]; + +// Shared meta config for Table stories +export const tableStoriesMeta = { + decorators: [ + (Story: () => JSX.Element) => ( + + + + ), + ], +} satisfies Partial; diff --git a/packages/ui/src/components/Table/types.ts b/packages/ui/src/components/Table/types.ts index 1ac477e3ff..5fec5355e5 100644 --- a/packages/ui/src/components/Table/types.ts +++ b/packages/ui/src/components/Table/types.ts @@ -16,9 +16,24 @@ import { CellProps as ReactAriaCellProps, - ColumnProps as AriaColumnProps, + ColumnProps as ReactAriaColumnProps, + TableProps as ReactAriaTableProps, } from 'react-aria-components'; +import type { ReactNode } from 'react'; +import type { SortDescriptor as ReactStatelySortDescriptor } from 'react-stately'; import type { TextColors } from '../../types'; +import { TablePaginationProps } from '../TablePagination'; + +/** + * @public + */ +export type SortDescriptor = ReactStatelySortDescriptor; + +/** @public */ +export interface SortState { + descriptor: SortDescriptor | null; + onSortChange: (descriptor: SortDescriptor) => void; +} /** @public */ export interface CellProps extends ReactAriaCellProps {} @@ -42,6 +57,76 @@ export interface CellProfileProps extends ReactAriaCellProps { } /** @public */ -export interface ColumnProps extends Omit { +export interface ColumnProps extends Omit { children?: React.ReactNode; } + +/** @public */ +export interface TableRootProps extends ReactAriaTableProps { + stale?: boolean; +} + +/** @public */ +export interface TableItem { + id: string | number; +} + +/** @public */ +export interface NoPagination { + type: 'none'; +} + +/** @public */ +export interface PagePagination extends TablePaginationProps { + type: 'page'; +} + +/** @public */ +export type TablePaginationType = NoPagination | PagePagination; + +/** @public */ +export interface ColumnConfig { + id: string; + label: string; + cell: (item: T) => ReactNode; + header?: () => ReactNode; + isSortable?: boolean; + isHidden?: boolean; + width?: number | string; + isRowHeader?: boolean; +} + +/** @public */ +export interface RowConfig { + getHref?: (item: T) => string | undefined; + onClick?: (item: T) => void; + getIsDisabled?: (item: T) => boolean; +} + +/** @public */ +export type RowRenderFn = (params: { + item: T; + index: number; +}) => ReactNode; + +/** @public */ +export interface TableSelection { + mode?: ReactAriaTableProps['selectionMode']; + behavior?: ReactAriaTableProps['selectionBehavior']; + selected?: ReactAriaTableProps['selectedKeys']; + onSelectionChange?: ReactAriaTableProps['onSelectionChange']; +} + +/** @public */ +export interface TableProps { + columnConfig: readonly ColumnConfig[]; + data: T[] | undefined; + loading?: boolean; + isStale?: boolean; + error?: Error; + pagination: TablePaginationType; + sort?: SortState; + rowConfig?: RowConfig | RowRenderFn; + selection?: TableSelection; + emptyState?: ReactNode; +} diff --git a/packages/ui/src/components/TablePagination/TablePagination.stories.tsx b/packages/ui/src/components/TablePagination/TablePagination.stories.tsx index a7d949a31d..dd76f7c55a 100644 --- a/packages/ui/src/components/TablePagination/TablePagination.stories.tsx +++ b/packages/ui/src/components/TablePagination/TablePagination.stories.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2025 The Backstage Authors + * Copyright 2024 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. @@ -13,41 +13,96 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import preview from '../../../../../.storybook/preview'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; import { TablePagination } from './TablePagination'; -const meta = preview.meta({ +const noop = () => {}; + +const meta = { title: 'Backstage UI/TablePagination', component: TablePagination, argTypes: { offset: { control: 'number' }, pageSize: { control: 'radio', options: [5, 10, 20, 30, 40, 50] }, - rowCount: { control: 'number' }, - showPageSizeOptions: { control: 'boolean', defaultValue: true }, - setOffset: { action: 'setOffset' }, - setPageSize: { action: 'setPageSize' }, + totalCount: { control: 'number' }, + hasNextPage: { control: 'boolean' }, + hasPreviousPage: { control: 'boolean' }, + showPageSizeOptions: { control: 'boolean' }, }, -}); +} satisfies Meta; -export const Default = meta.story({ +export default meta; +type Story = StoryObj; + +export const Default: Story = { args: { offset: 0, pageSize: 10, - rowCount: 100, + totalCount: 100, + hasNextPage: true, + hasPreviousPage: false, + onNextPage: noop, + onPreviousPage: noop, + onPageSizeChange: noop, + showPageSizeOptions: true, }, - render: args => { - // const [{}, updateArgs] = useArgs(); +}; - return ( - { - // updateArgs({ offset: value }); - // }} - // setPageSize={value => { - // updateArgs({ pageSize: value }); - // }} - /> - ); +export const FirstPage: Story = { + args: { + ...Default.args, }, -}); +}; + +export const LastPage: Story = { + args: { + ...Default.args, + offset: 90, + hasNextPage: false, + hasPreviousPage: true, + }, +}; + +export const MiddlePage: Story = { + args: { + ...Default.args, + offset: 40, + hasPreviousPage: true, + }, +}; + +export const WithoutPageSizeOptions: Story = { + args: { + ...Default.args, + showPageSizeOptions: false, + }, +}; + +export const CursorPagination: Story = { + args: { + ...Default.args, + offset: undefined, + }, +}; + +export const CustomLabel: Story = { + args: { + ...Default.args, + offset: 20, + hasPreviousPage: true, + getLabel: ({ offset, pageSize, totalCount }) => { + const page = Math.floor((offset ?? 0) / pageSize) + 1; + const totalPages = Math.ceil((totalCount ?? 0) / pageSize); + return `Page ${page} of ${totalPages}`; + }, + }, +}; + +export const EmptyState: Story = { + args: { + ...Default.args, + totalCount: 0, + hasNextPage: false, + }, +}; diff --git a/packages/ui/src/components/TablePagination/TablePagination.tsx b/packages/ui/src/components/TablePagination/TablePagination.tsx index fd891be073..de9234afa5 100644 --- a/packages/ui/src/components/TablePagination/TablePagination.tsx +++ b/packages/ui/src/components/TablePagination/TablePagination.tsx @@ -21,67 +21,47 @@ 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'; /** * Pagination controls for Table components with page navigation and size selection. * * @public */ -export function TablePagination(props: TablePaginationProps) { - const { classNames, cleanedProps } = useStyles(TablePaginationDefinition, { - showPageSizeOptions: true, - ...props, - }); - const { - className, - offset, - pageSize, - rowCount, - onNextPage, - onPreviousPage, - onPageSizeChange, - setOffset, - setPageSize, - showPageSizeOptions, - ...rest - } = cleanedProps; +export function TablePagination({ + pageSize, + offset, + totalCount, + hasNextPage, + hasPreviousPage, + onNextPage, + onPreviousPage, + onPageSizeChange, + showPageSizeOptions = true, + getLabel, +}: TablePaginationProps) { + const { classNames } = useStyles(TablePaginationDefinition, {}); + const labelId = useId(); - const currentOffset = offset ?? 0; - const currentPageSize = pageSize ?? 10; + const hasItems = totalCount !== undefined && totalCount !== 0; - const fromCount = currentOffset + 1; - const toCount = Math.min(currentOffset + currentPageSize, rowCount ?? 0); - - const nextPage = () => { - const totalRows = rowCount ?? 0; - const nextOffset = currentOffset + currentPageSize; - - // Check if there are more items to navigate to - if (nextOffset < totalRows) { - onNextPage?.(); // Analytics tracking - setOffset?.(nextOffset); // Navigate to next page - } - }; - - const previousPage = () => { - // Check if we can go to previous page - if (currentOffset > 0) { - onPreviousPage?.(); // Analytics tracking - const prevOffset = Math.max(0, currentOffset - currentPageSize); - setOffset?.(prevOffset); // Navigate to previous page - } - }; + let label = `${totalCount} items`; + if (getLabel) { + label = getLabel({ pageSize, offset, totalCount }); + } else if (offset !== undefined) { + const fromCount = offset + 1; + const toCount = Math.min(offset + pageSize, totalCount ?? 0); + label = `${fromCount} - ${toCount} of ${totalCount}`; + } return ( -
+
{showPageSizeOptions && (