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
   -<Table aria-label="My table">
   -  <TableHeader>...</TableHeader>
   -  <TableBody items={data}>...</TableBody>
   -</Table>
   -<TablePagination {...paginationProps} />
   +const columns: ColumnConfig<Item>[] = [
   +  { id: 'name', label: 'Name', isRowHeader: true, cell: item => <CellText title={item.name} /> },
   +  { id: 'type', label: 'Type', cell: item => <CellText title={item.type} /> },
   +];
   +
   +<Table columnConfig={columns} {...tableProps} />
   ```

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-12-06 20:23:21 +00:00
committed by Johan Persson
parent 3d7fdd786a
commit 243e5e7139
31 changed files with 3497 additions and 1254 deletions
+44
View File
@@ -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
-<Table aria-label="My table">
- <TableHeader>...</TableHeader>
- <TableBody items={data}>...</TableBody>
-</Table>
-<TablePagination {...paginationProps} />
+const columns: ColumnConfig<Item>[] = [
+ { id: 'name', label: 'Name', isRowHeader: true, cell: item => <CellText title={item.name} /> },
+ { id: 'type', label: 'Type', cell: item => <CellText title={item.type} /> },
+];
+
+<Table columnConfig={columns} {...tableProps} />
```
Affected components: Table, TableRoot, TablePagination
@@ -99,6 +99,7 @@ dataflow
dataloader
dayjs
debounce
debounced
debounces
debuggability
declaratively
+1 -1
View File
@@ -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';
+313 -46
View File
@@ -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<string, string>;
// @public (undocumented)
export const Column: (props: ColumnProps) => JSX_2.Element;
// @public (undocumented)
export interface ColumnConfig<T extends TableItem> {
// (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<ColumnProps_2, 'children'> {
// (undocumented)
@@ -523,6 +544,34 @@ export interface ContainerProps {
style?: React.CSSProperties;
}
// @public (undocumented)
export interface CursorParams<TFilter> {
// (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<T> {
// (undocumented)
data: T[];
// (undocumented)
nextCursor?: string;
// (undocumented)
prevCursor?: string;
// (undocumented)
totalCount?: number;
}
// @public
export type DataAttributesMap = Record<string, DataAttributeValues>;
@@ -627,6 +676,14 @@ export interface FieldLabelProps
secondaryLabel?: string | null;
}
// @public (undocumented)
export interface FilterState<TFilter> {
// (undocumented)
onFilterChange: (filter: TFilter) => void;
// (undocumented)
value: TFilter | undefined;
}
// @public (undocumented)
export const Flex: ForwardRefExoticComponent<
FlexProps & RefAttributes<HTMLDivElement>
@@ -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<TFilter> {
// (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<T> {
// (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<TFilter> {
// (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<HTMLLabelElement>
@@ -1082,6 +1209,22 @@ export type Responsive<T> = T | Partial<Record<Breakpoint, T>>;
// @public (undocumented)
export function Row<T extends object>(props: RowProps<T>): JSX_2.Element;
// @public (undocumented)
export interface RowConfig<T extends TableItem> {
// (undocumented)
getHref?: (item: T) => string | undefined;
// (undocumented)
getIsDisabled?: (item: T) => boolean;
// (undocumented)
onClick?: (item: T) => void;
}
// @public (undocumented)
export type RowRenderFn<T extends TableItem> = (params: {
item: T;
index: number;
}) => ReactNode;
// @public (undocumented)
export const SearchField: ForwardRefExoticComponent<
SearchFieldProps & RefAttributes<HTMLDivElement>
@@ -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<HTMLDivElement>
@@ -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<T extends TableItem>({
columnConfig,
data,
loading,
isStale,
error,
pagination,
sort,
rowConfig,
selection,
emptyState,
}: TableProps<T>): JSX_2.Element;
// @public (undocumented)
export const TableBody: <T extends object>(
@@ -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: <T extends object>(
props: TableHeaderProps<T>,
) => 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<HTMLDivElement> {
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<T extends TableItem> {
// (undocumented)
columnConfig: readonly ColumnConfig<T>[];
// (undocumented)
data: T[] | undefined;
// (undocumented)
emptyState?: ReactNode;
// (undocumented)
error?: Error;
// (undocumented)
isStale?: boolean;
// (undocumented)
loading?: boolean;
// (undocumented)
pagination: TablePaginationType;
// (undocumented)
rowConfig?: RowConfig<T> | RowRenderFn<T>;
// (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<T = any>(
config?: UseTableConfig<T>,
): UseTableResult<T>;
// @public (undocumented)
export function useTable<T extends TableItem, TFilter = unknown>(
options: UseTableOptions<T, TFilter>,
): UseTableResult<T, TFilter>;
// @public (undocumented)
export interface UseTableConfig<T = any> {
data?: T[];
pagination?: UseTablePaginationConfig;
export interface UseTableCompleteOptions<T extends TableItem, TFilter = unknown>
extends QueryOptions<TFilter> {
// (undocumented)
filterFn?: (data: T[], filter: TFilter) => T[];
// (undocumented)
getData: () => T[] | Promise<T[]>;
// (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<T = any> {
data?: T[];
nextPage: () => void;
offset: number;
pageSize: number;
paginationProps: TablePaginationProps;
previousPage: () => void;
setOffset: (offset: number) => void;
setPageSize: (pageSize: number) => void;
export interface UseTableCursorOptions<T extends TableItem, TFilter = unknown>
extends QueryOptions<TFilter> {
// (undocumented)
getData: (params: CursorParams<TFilter>) => Promise<CursorResponse<T>>;
// (undocumented)
mode: 'cursor';
// (undocumented)
paginationOptions?: Omit<PaginationOptions, 'initialOffset'>;
}
// @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<T extends TableItem, TFilter = unknown>
extends QueryOptions<TFilter> {
// (undocumented)
getData: (params: OffsetParams<TFilter>) => Promise<OffsetResponse<T>>;
// (undocumented)
mode: 'offset';
// (undocumented)
paginationOptions?: PaginationOptions;
}
// @public (undocumented)
export interface UseTableResult<T = any> {
data?: T[];
pagination: UseTablePagination<T>;
paginationProps: TablePaginationProps;
export type UseTableOptions<T extends TableItem, TFilter = unknown> =
| UseTableCompleteOptions<T, TFilter>
| UseTableOffsetOptions<T, TFilter>
| UseTableCursorOptions<T, TFilter>;
// @public (undocumented)
export interface UseTableResult<T extends TableItem, TFilter = unknown> {
// (undocumented)
filter: FilterState<TFilter>;
// (undocumented)
reload: () => void;
// (undocumented)
search: SearchState;
// (undocumented)
tableProps: Omit<
TableProps<T>,
'columnConfig' | 'rowConfig' | 'selection' | 'emptyState'
>;
}
// @public (undocumented)
@@ -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 {
@@ -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) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
});
// 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 (
<Table>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
<Column>Lifecycle</Column>
</TableHeader>
<TableBody>
{data1.map(item => (
<Row key={item.name}>
<CellText
title={item.name}
leadingIcon={<RiCactusLine />}
description={item.description}
/>
<CellProfile
name={item.owner.name}
src={item.owner.profilePicture}
href={item.owner.link}
/>
<CellText title={item.type} />
<CellText title={item.lifecycle} />
</Row>
))}
</TableBody>
</Table>
);
},
});
export const WithPaginationUncontrolled = meta.story({
render: () => {
const { data, paginationProps } = useTable({ data: data1 });
return (
<>
<Table>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
<Column>Lifecycle</Column>
</TableHeader>
<TableBody>
{data?.map(item => (
<Row key={item.name}>
<CellText
title={item.name}
leadingIcon={<RiCactusLine />}
description={item.description}
/>
<CellText title={item.owner.name} />
<CellText title={item.type} />
<CellText title={item.lifecycle} />
</Row>
))}
</TableBody>
</Table>
<TablePagination {...paginationProps} />
</>
);
},
});
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 (
<>
<Table>
<TableHeader>
<Column isRowHeader>Band name</Column>
<Column>Genre</Column>
<Column>Year formed</Column>
<Column>Albums</Column>
</TableHeader>
<TableBody>
{data?.map(item => (
<Row key={item.name}>
<CellProfile
name={item.name}
src={item.image}
href={item.website}
/>
<CellText title={item.genre} />
<CellText title={item.yearFormed.toString()} />
<CellText title={item.albums.toString()} />
</Row>
))}
</TableBody>
</Table>
<TablePagination {...paginationProps} />
<div style={{ marginTop: '16px', fontSize: '12px', color: '#666' }}>
Current state: offset={offset}, pageSize={pageSize}
</div>
</>
);
},
});
export const Sorting = meta.story({
render: () => {
return (
<Table>
<TableHeader>
<Column isRowHeader allowsSorting>
Name
</Column>
<Column allowsSorting>Owner</Column>
<Column allowsSorting>Type</Column>
<Column allowsSorting>Lifecycle</Column>
</TableHeader>
<TableBody>
{data1.map(item => (
<Row key={item.name}>
<CellText
title={item.name}
leadingIcon={<RiCactusLine />}
description={item.description}
/>
<CellProfile
name={item.owner.name}
src={item.owner.profilePicture}
href={item.owner.link}
/>
<CellText title={item.type} />
<CellText title={item.lifecycle} />
</Row>
))}
</TableBody>
</Table>
);
},
});
export const TableRockBand = meta.story({
render: () => {
const { data, paginationProps } = useTable({
data: data4,
pagination: {
defaultPageSize: 5,
},
});
return (
<>
<Table>
<TableHeader>
<Column isRowHeader>Band name</Column>
<Column>Genre</Column>
<Column>Year formed</Column>
<Column>Albums</Column>
</TableHeader>
<TableBody>
{data?.map(item => (
<Row key={item.name}>
<CellProfile
name={item.name}
src={item.image}
href={item.website}
/>
<CellText title={item.genre} />
<CellText title={item.yearFormed.toString()} />
<CellText title={item.albums.toString()} />
</Row>
))}
</TableBody>
</Table>
<TablePagination {...paginationProps} />
</>
);
},
});
export const RowClick = meta.story({
render: () => {
const { data, paginationProps } = useTable({
data: data4,
pagination: {
defaultPageSize: 5,
},
});
return (
<>
<Table>
<TableHeader>
<Column isRowHeader>Band name</Column>
<Column>Genre</Column>
<Column>Year formed</Column>
<Column>Albums</Column>
</TableHeader>
<TableBody>
{data?.map(item => (
<Row key={item.name} onAction={() => alert('Row clicked')}>
<CellProfile
name={item.name}
src={item.image}
href={item.website}
/>
<CellText title={item.genre} />
<CellText title={item.yearFormed.toString()} />
<CellText title={item.albums.toString()} />
</Row>
))}
</TableBody>
</Table>
<TablePagination {...paginationProps} />
</>
);
},
});
export const RowLink = meta.story({
render: () => {
const { data, paginationProps } = useTable({
data: data4,
pagination: {
defaultPageSize: 5,
},
});
return (
<>
<Table>
<TableHeader>
<Column isRowHeader>Band name</Column>
<Column>Genre</Column>
<Column>Year formed</Column>
<Column>Albums</Column>
</TableHeader>
<TableBody>
{data?.map(item => (
<Row key={item.name} href="/band">
<CellProfile
name={item.name}
src={item.image}
href={item.website}
/>
<CellText title={item.genre} />
<CellText title={item.yearFormed.toString()} />
<CellText title={item.albums.toString()} />
</Row>
))}
</TableBody>
</Table>
<TablePagination {...paginationProps} />
</>
);
},
});
export const CellComponent = meta.story({
name: 'Cell',
render: () => {
return (
<Table>
<TableHeader>
<Column isRowHeader>Name</Column>
</TableHeader>
<TableBody>
<Row>
<Cell>Hello world</Cell>
</Row>
<Row>
<Cell>
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.
</Cell>
</Row>
<Row>
<Cell>Hello world</Cell>
</Row>
</TableBody>
</Table>
);
},
});
export const CellTextComponent = meta.story({
name: 'CellText',
render: () => {
return (
<Table>
<TableHeader>
<Column isRowHeader>Name</Column>
</TableHeader>
<TableBody>
{data2.map(item => (
<Row key={item.name}>
<CellText
title={item.name}
leadingIcon={item.icon}
description={item.description}
href={item.href}
/>
</Row>
))}
</TableBody>
</Table>
);
},
});
export const CellProfileComponent = meta.story({
name: 'CellProfile',
render: () => {
return (
<Table>
<TableHeader>
<Column isRowHeader>Name</Column>
</TableHeader>
<TableBody>
{data3.map(item => (
<Row key={item.name}>
<CellProfile
name={item.name}
src={item.profilePicture}
href={item.link}
description={item.description}
/>
</Row>
))}
</TableBody>
</Table>
);
},
});
export const SelectionSingleToggle = meta.story({
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="single"
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<CellText title="Component Library" />
<CellText title="Design System" />
<CellText title="library" />
</Row>
<Row id="2">
<CellText title="API Gateway" />
<CellText title="Platform" />
<CellText title="service" />
</Row>
<Row id="3">
<CellText title="Documentation Site" />
<CellText title="DevEx" />
<CellText title="website" />
</Row>
</TableBody>
</Table>
);
},
});
export const SelectionMultiToggle = meta.story({
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="multiple"
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<CellText title="Component Library" />
<CellText title="Design System" />
<CellText title="library" />
</Row>
<Row id="2">
<CellText title="API Gateway" />
<CellText title="Platform" />
<CellText title="service" />
</Row>
<Row id="3">
<CellText title="Documentation Site" />
<CellText title="DevEx" />
<CellText title="website" />
</Row>
</TableBody>
</Table>
);
},
});
export const SelectionSingleReplace = meta.story({
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="single"
selectionBehavior="replace"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<CellText title="Component Library" />
<CellText title="Design System" />
<CellText title="library" />
</Row>
<Row id="2">
<CellText title="API Gateway" />
<CellText title="Platform" />
<CellText title="service" />
</Row>
<Row id="3">
<CellText title="Documentation Site" />
<CellText title="DevEx" />
<CellText title="website" />
</Row>
</TableBody>
</Table>
);
},
});
export const SelectionMultiReplace = meta.story({
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="multiple"
selectionBehavior="replace"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<CellText title="Component Library" />
<CellText title="Design System" />
<CellText title="library" />
</Row>
<Row id="2">
<CellText title="API Gateway" />
<CellText title="Platform" />
<CellText title="service" />
</Row>
<Row id="3">
<CellText title="Documentation Site" />
<CellText title="DevEx" />
<CellText title="website" />
</Row>
</TableBody>
</Table>
);
},
});
export const SelectionToggleWithActions = meta.story({
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="multiple"
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
onRowAction={key => alert(`Opening ${key}`)}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<CellText title="Component Library" />
<CellText title="Design System" />
<CellText title="library" />
</Row>
<Row id="2">
<CellText title="API Gateway" />
<CellText title="Platform" />
<CellText title="service" />
</Row>
<Row id="3">
<CellText title="Documentation Site" />
<CellText title="DevEx" />
<CellText title="website" />
</Row>
</TableBody>
</Table>
);
},
});
export const SelectionReplaceWithActions = meta.story({
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="multiple"
selectionBehavior="replace"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
onRowAction={key => alert(`Opening ${key}`)}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<CellText title="Component Library" />
<CellText title="Design System" />
<CellText title="library" />
</Row>
<Row id="2">
<CellText title="API Gateway" />
<CellText title="Platform" />
<CellText title="service" />
</Row>
<Row id="3">
<CellText title="Documentation Site" />
<CellText title="DevEx" />
<CellText title="website" />
</Row>
</TableBody>
</Table>
);
},
});
export const SelectionToggleWithLinks = meta.story({
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="multiple"
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1" href="https://example.com/library">
<CellText title="Component Library" />
<CellText title="Design System" />
<CellText title="library" />
</Row>
<Row id="2" href="https://example.com/gateway">
<CellText title="API Gateway" />
<CellText title="Platform" />
<CellText title="service" />
</Row>
<Row id="3" href="https://example.com/docs">
<CellText title="Documentation Site" />
<CellText title="DevEx" />
<CellText title="website" />
</Row>
</TableBody>
</Table>
);
},
});
export const SelectionReplaceWithLinks = meta.story({
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="multiple"
selectionBehavior="replace"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1" href="https://example.com/library">
<CellText title="Component Library" />
<CellText title="Design System" />
<CellText title="library" />
</Row>
<Row id="2" href="https://example.com/gateway">
<CellText title="API Gateway" />
<CellText title="Platform" />
<CellText title="service" />
</Row>
<Row id="3" href="https://example.com/docs">
<CellText title="Documentation Site" />
<CellText title="DevEx" />
<CellText title="website" />
</Row>
</TableBody>
</Table>
);
},
});
export const SelectionWithDisabledRows = meta.story({
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="multiple"
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
disabledKeys={['2']}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<CellText title="Component Library" />
<CellText title="Design System" />
<CellText title="library" />
</Row>
<Row id="2">
<CellText title="API Gateway (Disabled)" />
<CellText title="Platform" />
<CellText title="service" />
</Row>
<Row id="3">
<CellText title="Documentation Site" />
<CellText title="DevEx" />
<CellText title="website" />
</Row>
</TableBody>
</Table>
);
},
});
export const SelectionWithPagination = meta.story({
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
const { data, paginationProps } = useTable({
data: data1,
pagination: {
defaultPageSize: 5,
},
});
return (
<>
<Table
selectionMode="multiple"
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
{data?.map(item => (
<Row key={item.name} id={item.name}>
<CellText title={item.name} />
<CellText title={item.owner.name} />
<CellText title={item.type} />
</Row>
))}
</TableBody>
</Table>
<TablePagination {...paginationProps} />
</>
);
},
});
export const SelectionModePlayground = meta.story({
render: () => {
const [selectionMode, setSelectionMode] = useState<'single' | 'multiple'>(
'multiple',
);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Flex direction="column" gap="8">
<Table
selectionMode={selectionMode}
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<CellText title="Component Library" />
<CellText title="Design System" />
<CellText title="library" />
</Row>
<Row id="2">
<CellText title="API Gateway" />
<CellText title="Platform" />
<CellText title="service" />
</Row>
<Row id="3">
<CellText title="Documentation Site" />
<CellText title="DevEx" />
<CellText title="website" />
</Row>
</TableBody>
</Table>
<div>
<Text as="h4" style={{ marginBottom: 'var(--bui-space-2)' }}>
Selection mode:
</Text>
<RadioGroup
aria-label="Selection mode"
orientation="horizontal"
value={selectionMode}
onChange={value => {
setSelectionMode(value as 'single' | 'multiple');
setSelectedKeys(new Set([]));
}}
>
<Radio value="single">single</Radio>
<Radio value="multiple">multiple</Radio>
</RadioGroup>
</div>
</Flex>
);
},
});
export const SelectionBehaviorPlayground = meta.story({
render: () => {
const [selectionBehavior, setSelectionBehavior] = useState<
'toggle' | 'replace'
>('toggle');
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Flex direction="column" gap="8">
<Table
selectionMode="multiple"
selectionBehavior={selectionBehavior}
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<CellText title="Component Library" />
<CellText title="Design System" />
<CellText title="library" />
</Row>
<Row id="2">
<CellText title="API Gateway" />
<CellText title="Platform" />
<CellText title="service" />
</Row>
<Row id="3">
<CellText title="Documentation Site" />
<CellText title="DevEx" />
<CellText title="website" />
</Row>
</TableBody>
</Table>
<div>
<Text as="h4" style={{ marginBottom: 'var(--bui-space-2)' }}>
Selection behavior:
</Text>
<RadioGroup
aria-label="Selection behavior"
orientation="horizontal"
value={selectionBehavior}
onChange={value => {
setSelectionBehavior(value as 'toggle' | 'replace');
setSelectedKeys(new Set([]));
}}
>
<Radio value="toggle">toggle</Radio>
<Radio value="replace">replace</Radio>
</RadioGroup>
</div>
</Flex>
);
},
});
@@ -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<T extends TableItem>(
rowConfig: RowConfig<T> | RowRenderFn<T> | undefined,
): rowConfig is RowRenderFn<T> {
return typeof rowConfig === 'function';
}
function useDisabledRows<T extends TableItem>({
data,
rowConfig,
}: Pick<TableProps<T>, 'data' | 'rowConfig'>): Set<Key> | undefined {
return useMemo(() => {
if (!data || typeof rowConfig === 'function' || !rowConfig?.getIsDisabled) {
return;
}
return data.reduce<Set<Key>>((set, item) => {
const isDisabled = rowConfig.getIsDisabled?.(item);
if (isDisabled) {
set.add(String(item.id));
}
return set;
}, new Set<Key>());
}, [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<T extends TableItem>({
columnConfig,
data,
loading = false,
isStale = false,
error,
pagination,
sort,
rowConfig,
selection,
emptyState,
}: TableProps<T>) {
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 <div>Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
const liveRegionLabel = useLiveRegionLabel(
pagination,
isStale,
data !== undefined,
);
return (
<ReactAriaTable
className={clsx(classNames.table, styles[classNames.table])}
aria-label="Data table"
{...cleanedProps}
/>
<div>
<VisuallyHidden aria-live="polite" id={liveRegionId}>
{liveRegionLabel}
</VisuallyHidden>
<TableRoot
selectionMode={selectionMode}
selectionBehavior={selectionBehavior}
selectedKeys={selectedKeys}
onSelectionChange={onSelectionChange}
sortDescriptor={sort?.descriptor ?? undefined}
onSortChange={sort?.onSortChange}
disabledKeys={disabledRows}
stale={isStale}
aria-describedby={liveRegionId}
>
<TableHeader columns={visibleColumns}>
{column =>
column.header ? (
<>{column.header()}</>
) : (
<Column
id={column.id}
isRowHeader={column.isRowHeader}
allowsSorting={column.isSortable}
>
{column.label}
</Column>
)
}
</TableHeader>
<TableBody
items={data}
renderEmptyState={
emptyState ? () => <Flex p="3">{emptyState}</Flex> : undefined
}
>
{item => {
const itemIndex = data?.indexOf(item) ?? -1;
if (isRowRenderFn(rowConfig)) {
return rowConfig({
item,
index: itemIndex,
});
}
return (
<Row
id={String(item.id)}
columns={visibleColumns}
href={rowConfig?.getHref?.(item)}
onAction={
rowConfig?.onClick
? () => rowConfig?.onClick?.(item)
: undefined
}
>
{column => (
<Fragment key={column.id}>{column.cell(item)}</Fragment>
)}
</Row>
);
}}
</TableBody>
</TableRoot>
{pagination.type === 'page' && (
<TablePagination
pageSize={pagination.pageSize}
offset={pagination.offset}
totalCount={pagination.totalCount}
hasNextPage={pagination.hasNextPage}
hasPreviousPage={pagination.hasPreviousPage}
onNextPage={pagination.onNextPage}
onPreviousPage={pagination.onPreviousPage}
onPageSizeChange={pagination.onPageSizeChange}
showPageSizeOptions={pagination.showPageSizeOptions}
getLabel={pagination.getLabel}
/>
)}
</div>
);
};
}
@@ -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 (
<ReactAriaTable
className={clsx(classNames.table, styles[classNames.table])}
aria-label="Data table"
aria-busy={props.stale}
{...dataAttributes}
{...cleanedProps}
/>
);
};
@@ -42,4 +42,7 @@ export const TableDefinition = {
headSelection: 'bui-TableHeadSelection',
cellSelection: 'bui-TableCellSelection',
},
dataAttributes: {
stale: [true, false] as const,
},
} as const satisfies ComponentDefinition;
+124 -53
View File
@@ -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<TFilter> {
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<TFilter> {
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<T = any> {
/** Props to pass to TablePagination component */
paginationProps: TablePaginationProps;
/** Current offset */
export interface OffsetParams<TFilter> {
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<T = any> {
/** Full dataset - when provided, rowCount is calculated automatically and sliced data is returned */
data?: T[];
/** Pagination configuration */
pagination?: UseTablePaginationConfig;
export interface CursorParams<TFilter> {
cursor: string | undefined;
pageSize: number;
sort: SortDescriptor | null;
filter: TFilter | undefined;
search: string;
signal: AbortSignal;
}
/** @public */
export interface UseTableResult<T = any> {
/** Sliced data for current page */
data?: T[];
/** Props to pass to TablePagination component */
paginationProps: TablePaginationProps;
/** Pagination utilities */
pagination: UseTablePagination<T>;
export interface OffsetResponse<T> {
data: T[];
totalCount: number;
}
/** @public */
export interface CursorResponse<T> {
data: T[];
nextCursor?: string;
prevCursor?: string;
totalCount?: number;
}
/** @public */
export interface UseTableCompleteOptions<T extends TableItem, TFilter = unknown>
extends QueryOptions<TFilter> {
mode: 'complete';
getData: () => T[] | Promise<T[]>;
paginationOptions?: PaginationOptions;
sortFn?: (data: T[], sort: SortDescriptor) => T[];
filterFn?: (data: T[], filter: TFilter) => T[];
searchFn?: (data: T[], search: string) => T[];
}
/** @public */
export interface UseTableOffsetOptions<T extends TableItem, TFilter = unknown>
extends QueryOptions<TFilter> {
mode: 'offset';
getData: (params: OffsetParams<TFilter>) => Promise<OffsetResponse<T>>;
paginationOptions?: PaginationOptions;
}
/** @public */
export interface UseTableCursorOptions<T extends TableItem, TFilter = unknown>
extends QueryOptions<TFilter> {
mode: 'cursor';
getData: (params: CursorParams<TFilter>) => Promise<CursorResponse<T>>;
paginationOptions?: Omit<PaginationOptions, 'initialOffset'>;
}
/** @public */
export type UseTableOptions<T extends TableItem, TFilter = unknown> =
| UseTableCompleteOptions<T, TFilter>
| UseTableOffsetOptions<T, TFilter>
| UseTableCursorOptions<T, TFilter>;
/** @public */
export interface UseTableResult<T extends TableItem, TFilter = unknown> {
tableProps: Omit<
TableProps<T>,
'columnConfig' | 'rowConfig' | 'selection' | 'emptyState'
>;
reload: () => void;
filter: FilterState<TFilter>;
search: SearchState;
}
/** @internal */
export interface PaginationResult<T> {
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<TFilter> {
sort: SortDescriptor | null;
setSort: (sort: SortDescriptor) => void;
filter: TFilter | undefined;
setFilter: (filter: TFilter) => void;
search: string;
setSearch: (search: string) => void;
}
@@ -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<T extends TableItem, TFilter>(
options: UseTableCompleteOptions<T, TFilter>,
query: QueryState<TFilter>,
): PaginationResult<T> & { 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<T[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | undefined>(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,
};
}
@@ -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<T extends TableItem, TFilter>(
options: UseTableCursorOptions<T, TFilter>,
query: QueryState<TFilter>,
): PaginationResult<T> & { reload: () => void } {
const { getData: getDataProp, paginationOptions = {} } = options;
const { pageSize: defaultPageSize = 20 } = paginationOptions;
const 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<TFilter> = {
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<T, string>({ 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,
};
}
@@ -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<TFilter>(
query: QueryState<TFilter>,
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]);
}
@@ -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<T extends TableItem, TFilter>(
options: UseTableOffsetOptions<T, TFilter>,
query: QueryState<TFilter>,
): PaginationResult<T> & { reload: () => void } {
const { getData: getDataProp, paginationOptions = {} } = options;
const { pageSize: defaultPageSize = 20, initialOffset = 0 } =
paginationOptions;
const 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<TFilter> = {
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<T, number>({
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,
};
}
@@ -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 extends CursorType> =
| TCursor
| typeof FIRST_PAGE_CURSOR;
interface PageEntry<T, TCursor extends CursorType> {
data: T[] | undefined;
nextCursor: InternalCursor<TCursor> | undefined;
prevCursor: InternalCursor<TCursor> | undefined;
}
interface GetDataResult<T, TCursor extends CursorType> {
data: T[];
nextCursor?: TCursor;
prevCursor?: TCursor;
totalCount?: number;
}
/** @internal */
export interface UsePageCacheOptions<T, TCursor extends CursorType = string> {
getData: (params: {
cursor: TCursor | undefined;
signal: AbortSignal;
}) => Promise<GetDataResult<T, TCursor>>;
initialCurrentCursor?: TCursor;
}
/** @internal */
export interface UsePageCacheResult<T, TCursor extends CursorType = string> {
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<T, TCursor extends CursorType> {
private cache = new Map<InternalCursor<TCursor>, PageEntry<T, TCursor>>();
get(cursor: InternalCursor<TCursor>): PageEntry<T, TCursor> | undefined {
return this.cache.get(cursor);
}
getOrCreate(cursor: InternalCursor<TCursor>): PageEntry<T, TCursor> {
const existing = this.cache.get(cursor);
if (existing) {
return existing;
}
const entry: PageEntry<T, TCursor> = {
data: undefined,
nextCursor: undefined,
prevCursor: undefined,
};
this.cache.set(cursor, entry);
return entry;
}
clear() {
this.cache.clear();
}
getTargetCursor(
direction: Direction,
currentCursor: InternalCursor<TCursor>,
initialCurrentCursor: TCursor | undefined,
): InternalCursor<TCursor> | 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<T, TCursor>,
direction: Direction,
currentCursor: InternalCursor<TCursor>,
) {
if (direction === 'next') {
entry.prevCursor = currentCursor;
} else if (direction === 'prev') {
entry.nextCursor = currentCursor;
}
}
}
function toInternalCursor<TCursor extends CursorType>(
cursor: TCursor | undefined,
): InternalCursor<TCursor> {
return cursor === undefined ? FIRST_PAGE_CURSOR : cursor;
}
function toExternalCursor<TCursor extends CursorType>(
cursor: InternalCursor<TCursor>,
): TCursor | undefined {
return cursor === FIRST_PAGE_CURSOR ? undefined : cursor;
}
/** @internal */
export function usePageCache<T, TCursor extends CursorType = string>(
options: UsePageCacheOptions<T, TCursor>,
): UsePageCacheResult<T, TCursor> {
const { getData, initialCurrentCursor } = options;
const [currentCursor, setCurrentCursor] = useState<InternalCursor<TCursor>>(
() => toInternalCursor(initialCurrentCursor),
);
const cacheStore = useRef(new PageCacheStore<T, TCursor>()).current;
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | undefined>(undefined);
const [totalCount, setTotalCount] = useState<number | undefined>(undefined);
const abortControllerRef = useRef<AbortController | null>(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,
};
}
@@ -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<T, TControlled = T, TInitial = TControlled>(
initialValue: TInitial,
controlledValue: TControlled | undefined,
onChange: ((value: T) => void) | undefined,
) {
const [internalValue, setInternalValue] = useState<TInitial>(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<TFilter>(
options: QueryOptions<TFilter>,
): QueryState<TFilter> {
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],
);
}
@@ -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<T extends (...args: any[]) => any>(fn: T): T {
const ref = useRef(fn);
ref.current = fn;
return useCallback((...args: Parameters<T>) => ref.current(...args), []) as T;
}
@@ -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<T = any>(
config: UseTableConfig<T> = {},
): UseTableResult<T> {
const { data, pagination: paginationConfig = {} } = config;
function useTableProps<T extends TableItem>(
paginationResult: PaginationResult<T>,
sortState: SortState,
paginationOptions: PaginationOptions = {},
): Omit<
TableProps<T>,
'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<T> = 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<T extends TableItem, TFilter = unknown>(
options: UseTableOptions<T, TFilter>,
): UseTableResult<T, TFilter> {
const query = useQueryState<TFilter>(options);
let pagination: PaginationResult<T> & { 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 },
};
}
+25 -3
View File
@@ -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';
@@ -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<typeof meta>;
type Data1Item = (typeof data1)[0];
type Data4Item = (typeof data4)[0];
export const BasicLocalData: Story = {
render: () => {
const columns: ColumnConfig<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => (
<CellText title={item.name} description={item.description} />
),
},
{
id: 'owner',
label: 'Owner',
cell: item => <CellText title={item.owner.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
{
id: 'lifecycle',
label: 'Lifecycle',
cell: item => <CellText title={item.lifecycle} />,
},
];
const { tableProps } = useTable({
mode: 'complete',
getData: () => data1,
paginationOptions: { pageSize: 5 },
});
return <Table columnConfig={columns} {...tableProps} />;
},
};
export const Sorting: Story = {
render: () => {
const columns: ColumnConfig<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => <CellText title={item.name} />,
isSortable: true,
},
{
id: 'owner',
label: 'Owner',
cell: item => <CellText title={item.owner.name} />,
isSortable: true,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
isSortable: true,
},
{
id: 'lifecycle',
label: 'Lifecycle',
cell: item => <CellText title={item.lifecycle} />,
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 <Table columnConfig={columns} {...tableProps} />;
},
};
export const Search: Story = {
render: () => {
const columns: ColumnConfig<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => <CellText title={item.name} />,
isSortable: true,
},
{
id: 'owner',
label: 'Owner',
cell: item => <CellText title={item.owner.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
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 (
<div>
<TextField
aria-label="Search"
placeholder="Search..."
value={search.value}
onChange={value => search.onSearchChange(value)}
style={{ marginBottom: '16px' }}
/>
<Table
columnConfig={columns}
emptyState={
search.value ? (
<div>No results found</div>
) : (
<div>No data available</div>
)
}
{...tableProps}
/>
</div>
);
},
};
export const Selection: Story = {
render: () => {
const [selected, setSelected] = useState<Set<string | number> | 'all'>(
new Set(),
);
const columns: ColumnConfig<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => <CellText title={item.name} />,
},
{
id: 'owner',
label: 'Owner',
cell: item => <CellText title={item.owner.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
const { tableProps } = useTable({
mode: 'complete',
getData: () => data1,
paginationOptions: { pageSize: 5 },
});
return (
<Table
{...tableProps}
columnConfig={columns}
selection={{
mode: 'multiple',
selected,
onSelectionChange: setSelected,
}}
/>
);
},
};
export const RowLinks: Story = {
render: () => {
const columns: ColumnConfig<Data4Item>[] = [
{
id: 'name',
label: 'Band name',
isRowHeader: true,
cell: item => <CellProfile name={item.name} src={item.image} />,
},
{
id: 'genre',
label: 'Genre',
cell: item => <CellText title={item.genre} />,
},
{
id: 'yearFormed',
label: 'Year formed',
cell: item => <CellText title={item.yearFormed.toString()} />,
},
];
const { tableProps } = useTable({
mode: 'complete',
getData: () => data4,
paginationOptions: { pageSize: 5 },
});
return (
<Table
{...tableProps}
columnConfig={columns}
rowConfig={{ getHref: item => `/bands/${item.id}` }}
/>
);
},
};
export const Reload: Story = {
render: () => {
const columns: ColumnConfig<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => <CellText title={item.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
const { tableProps, reload } = useTable({
mode: 'complete',
getData: () => data1,
paginationOptions: { pageSize: 5 },
});
return (
<div>
<Button onPress={() => reload()}>Refresh Data</Button>
<Table columnConfig={columns} {...tableProps} />
</div>
);
},
};
export const ServerSidePaginationOffset: Story = {
render: () => {
const columns: ColumnConfig<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => <CellText title={item.name} />,
},
{
id: 'owner',
label: 'Owner',
cell: item => <CellText title={item.owner.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
const { tableProps } = useTable({
mode: '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 <Table columnConfig={columns} {...tableProps} />;
},
};
export const ServerSidePaginationCursor: Story = {
render: () => {
const columns: ColumnConfig<Data4Item>[] = [
{
id: 'name',
label: 'Band name',
isRowHeader: true,
cell: item => <CellProfile name={item.name} src={item.image} />,
},
{
id: 'genre',
label: 'Genre',
cell: item => <CellText title={item.genre} />,
},
];
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 <Table columnConfig={columns} {...tableProps} />;
},
};
export const CustomRowRender: Story = {
render: () => {
const columns: ColumnConfig<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => <CellText title={item.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
{
id: 'lifecycle',
label: 'Lifecycle',
cell: item => <CellText title={item.lifecycle} />,
},
];
const { tableProps } = useTable({
mode: 'complete',
getData: () => data1,
paginationOptions: { pageSize: 5 },
});
return (
<Table
{...tableProps}
columnConfig={columns}
rowConfig={({ item }) => (
<Row
id={String(item.id)}
columns={columns}
style={{
background:
item.lifecycle === 'experimental'
? 'var(--bui-bg-warning)'
: undefined,
borderLeft:
item.lifecycle === 'experimental'
? '3px solid var(--bui-fg-warning)'
: '3px solid transparent',
}}
>
{column => (
<Fragment key={column.id}>
{column.id === 'name' ? (
<CellText title={item.name} description={item.description} />
) : (
column.cell(item)
)}
</Fragment>
)}
</Row>
)}
/>
);
},
};
export const AtomicComponents: Story = {
render: () => {
const displayData = data1.slice(0, 5);
return (
<TableRoot>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
{displayData.map(item => (
<Row key={item.id} id={String(item.id)}>
<CellText title={item.name} />
<CellText title={item.owner.name} />
<CellText title={item.type} />
</Row>
))}
</TableBody>
</TableRoot>
);
},
};
export const RowClick: Story = {
render: () => {
const columns: ColumnConfig<Data4Item>[] = [
{
id: 'name',
label: 'Band name',
isRowHeader: true,
cell: item => (
<CellProfile name={item.name} src={item.image} href={item.website} />
),
},
{
id: 'genre',
label: 'Genre',
cell: item => <CellText title={item.genre} />,
},
{
id: 'yearFormed',
label: 'Year formed',
cell: item => <CellText title={item.yearFormed.toString()} />,
},
{
id: 'albums',
label: 'Albums',
cell: item => <CellText title={item.albums.toString()} />,
},
];
const { tableProps } = useTable({
mode: 'complete',
getData: () => data4,
paginationOptions: { pageSize: 5 },
});
return (
<Table
{...tableProps}
columnConfig={columns}
rowConfig={{ onClick: item => alert(`Clicked: ${item.name}`) }}
/>
);
},
};
export const SelectionSingleToggle: Story = {
render: () => {
const [selected, setSelected] = useState<Set<string | number> | 'all'>(
new Set(),
);
const { tableProps } = useTable({
mode: 'complete',
getData: () => selectionData,
paginationOptions: { pageSize: 10 },
});
return (
<Table
{...tableProps}
columnConfig={selectionColumns}
selection={{
mode: 'single',
behavior: 'toggle',
selected,
onSelectionChange: setSelected,
}}
/>
);
},
};
export const SelectionMultiToggle: Story = {
render: () => {
const [selected, setSelected] = useState<Set<string | number> | 'all'>(
new Set(),
);
const { tableProps } = useTable({
mode: 'complete',
getData: () => selectionData,
paginationOptions: { pageSize: 10 },
});
return (
<Table
{...tableProps}
columnConfig={selectionColumns}
selection={{
mode: 'multiple',
behavior: 'toggle',
selected,
onSelectionChange: setSelected,
}}
/>
);
},
};
export const SelectionWithRowClick: Story = {
render: () => {
const [selected, setSelected] = useState<Set<string | number> | 'all'>(
new Set(),
);
const { tableProps } = useTable({
mode: 'complete',
getData: () => selectionData,
paginationOptions: { pageSize: 10 },
});
return (
<Table
{...tableProps}
columnConfig={selectionColumns}
selection={{
mode: 'multiple',
behavior: 'toggle',
selected,
onSelectionChange: setSelected,
}}
rowConfig={{ onClick: item => alert(`Clicked: ${item.name}`) }}
/>
);
},
};
export const SelectionWithRowLinks: Story = {
render: () => {
const [selected, setSelected] = useState<Set<string | number> | 'all'>(
new Set(),
);
const { tableProps } = useTable({
mode: 'complete',
getData: () => selectionData,
paginationOptions: { pageSize: 10 },
});
return (
<Table
{...tableProps}
columnConfig={selectionColumns}
selection={{
mode: 'multiple',
behavior: 'toggle',
selected,
onSelectionChange: setSelected,
}}
rowConfig={{ getHref: item => `/items/${item.id}` }}
/>
);
},
};
export const SelectionWithPagination: Story = {
render: () => {
const [selected, setSelected] = useState<Set<string | number> | 'all'>(
new Set(),
);
const { tableProps } = useTable({
mode: 'complete',
getData: () => data1,
paginationOptions: { pageSize: 5 },
});
const columns: ColumnConfig<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => <CellText title={item.name} />,
},
{
id: 'owner',
label: 'Owner',
cell: item => <CellText title={item.owner.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
return (
<Table
{...tableProps}
columnConfig={columns}
selection={{
mode: 'multiple',
behavior: 'toggle',
selected,
onSelectionChange: setSelected,
}}
/>
);
},
};
export const SelectionSingleReplace: Story = {
render: () => {
const [selected, setSelected] = useState<Set<string | number> | 'all'>(
new Set(),
);
const { tableProps } = useTable({
mode: 'complete',
getData: () => selectionData,
paginationOptions: { pageSize: 10 },
});
return (
<Table
{...tableProps}
columnConfig={selectionColumns}
selection={{
mode: 'single',
behavior: 'replace',
selected,
onSelectionChange: setSelected,
}}
/>
);
},
};
export const SelectionMultiReplace: Story = {
render: () => {
const [selected, setSelected] = useState<Set<string | number> | 'all'>(
new Set(),
);
const { tableProps } = useTable({
mode: 'complete',
getData: () => selectionData,
paginationOptions: { pageSize: 10 },
});
return (
<Table
{...tableProps}
columnConfig={selectionColumns}
selection={{
mode: 'multiple',
behavior: 'replace',
selected,
onSelectionChange: setSelected,
}}
/>
);
},
};
export const SelectionReplaceWithRowClick: Story = {
render: () => {
const [selected, setSelected] = useState<Set<string | number> | 'all'>(
new Set(),
);
const { tableProps } = useTable({
mode: 'complete',
getData: () => selectionData,
paginationOptions: { pageSize: 10 },
});
return (
<Table
{...tableProps}
columnConfig={selectionColumns}
selection={{
mode: 'multiple',
behavior: 'replace',
selected,
onSelectionChange: setSelected,
}}
rowConfig={{ onClick: item => alert(`Opening ${item.name}`) }}
/>
);
},
};
export const SelectionReplaceWithRowLinks: Story = {
render: () => {
const [selected, setSelected] = useState<Set<string | number> | 'all'>(
new Set(),
);
const { tableProps } = useTable({
mode: 'complete',
getData: () => selectionData,
paginationOptions: { pageSize: 10 },
});
return (
<Table
{...tableProps}
columnConfig={selectionColumns}
selection={{
mode: 'multiple',
behavior: 'replace',
selected,
onSelectionChange: setSelected,
}}
rowConfig={{ getHref: item => `/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<Set<string | number> | '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<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
isSortable: true,
cell: item => (
<CellText title={item.name} description={item.description} />
),
},
{
id: 'owner',
label: 'Owner',
isSortable: true,
cell: item => <CellText title={item.owner.name} />,
},
{
id: 'type',
label: 'Type',
isSortable: true,
cell: item => <CellText title={item.type} />,
},
{
id: 'lifecycle',
label: 'Lifecycle',
isSortable: true,
cell: item => <CellText title={item.lifecycle} />,
},
];
const { tableProps, search, filter } = useTable<Data1Item, TypeFilter>({
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 (
<Flex direction="column" gap="4">
<Flex gap="4" align="end">
<TextField
aria-label="Search"
label="Search"
placeholder="Search by name, owner, or description..."
value={search.value}
onChange={search.onSearchChange}
style={{ width: 300 }}
/>
<Select
label="Type"
options={typeOptions}
selectedKey={filter.value?.type ?? ''}
onSelectionChange={key =>
filter.onFilterChange({ type: key === '' ? null : String(key) })
}
style={{ width: 180 }}
/>
</Flex>
<Table
{...tableProps}
columnConfig={columns}
selection={{
mode: 'multiple',
behavior: 'toggle',
selected,
onSelectionChange: setSelected,
}}
emptyState={
search.value || filter.value?.type ? (
<div>No results match your filters</div>
) : (
<div>No data available</div>
)
}
/>
</Flex>
);
},
};
@@ -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<typeof meta>;
type Data4Item = (typeof data4)[0];
export const TableRockBand: Story = {
render: () => {
const columns: ColumnConfig<Data4Item>[] = [
{
id: 'name',
label: 'Band name',
isRowHeader: true,
cell: item => (
<CellProfile name={item.name} src={item.image} href={item.website} />
),
},
{
id: 'genre',
label: 'Genre',
cell: item => <CellText title={item.genre} />,
},
{
id: 'yearFormed',
label: 'Year formed',
cell: item => <CellText title={item.yearFormed.toString()} />,
},
{
id: 'albums',
label: 'Albums',
cell: item => <CellText title={item.albums.toString()} />,
},
];
const { tableProps } = useTable({
mode: 'complete',
getData: () => data4,
paginationOptions: { pageSize: 5 },
});
return <Table columnConfig={columns} {...tableProps} />;
},
};
export const SelectionToggleWithActions: Story = {
render: () => {
const [selected, setSelected] = useState<Set<string | number> | 'all'>(
new Set(),
);
const { tableProps } = useTable({
mode: 'complete',
getData: () => selectionData,
paginationOptions: { pageSize: 10 },
});
return (
<Table
{...tableProps}
columnConfig={selectionColumns}
selection={{
mode: 'multiple',
behavior: 'toggle',
selected,
onSelectionChange: setSelected,
}}
rowConfig={{ onClick: item => alert(`Clicked: ${item.name}`) }}
/>
);
},
};
export const SelectionModePlayground: Story = {
render: () => {
const [selectionMode, setSelectionMode] = useState<'single' | 'multiple'>(
'multiple',
);
const [selected, setSelected] = useState<Set<string | number> | 'all'>(
new Set(),
);
const { tableProps } = useTable({
mode: 'complete',
getData: () => selectionData,
paginationOptions: { pageSize: 10 },
});
return (
<Flex direction="column" gap="8">
<Table
{...tableProps}
columnConfig={selectionColumns}
selection={{
mode: selectionMode,
behavior: 'toggle',
selected,
onSelectionChange: setSelected,
}}
/>
<div>
<Text as="h4" style={{ marginBottom: 'var(--bui-space-2)' }}>
Selection mode:
</Text>
<RadioGroup
aria-label="Selection mode"
orientation="horizontal"
value={selectionMode}
onChange={value => {
setSelectionMode(value as 'single' | 'multiple');
setSelected(new Set());
}}
>
<Radio value="single">single</Radio>
<Radio value="multiple">multiple</Radio>
</RadioGroup>
</div>
</Flex>
);
},
};
export const SelectionBehaviorPlayground: Story = {
render: () => {
const [selectionBehavior, setSelectionBehavior] = useState<
'toggle' | 'replace'
>('toggle');
const [selected, setSelected] = useState<Set<string | number> | 'all'>(
new Set(),
);
const { tableProps } = useTable({
mode: 'complete',
getData: () => selectionData,
paginationOptions: { pageSize: 10 },
});
return (
<Flex direction="column" gap="8">
<Table
{...tableProps}
columnConfig={selectionColumns}
selection={{
mode: 'multiple',
behavior: selectionBehavior,
selected,
onSelectionChange: setSelected,
}}
/>
<div>
<Text as="h4" style={{ marginBottom: 'var(--bui-space-2)' }}>
Selection behavior:
</Text>
<RadioGroup
aria-label="Selection behavior"
orientation="horizontal"
value={selectionBehavior}
onChange={value => {
setSelectionBehavior(value as 'toggle' | 'replace');
setSelected(new Set());
}}
>
<Radio value="toggle">toggle</Radio>
<Radio value="replace">replace</Radio>
</RadioGroup>
</div>
</Flex>
);
},
};
@@ -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<typeof meta>;
type Data1Item = (typeof data1)[0];
type Data4Item = (typeof data4)[0];
type CellTextVariantsItem = (typeof cellTextVariantsData)[0];
export const ProfileCells: Story = {
render: () => {
const columns: ColumnConfig<Data4Item>[] = [
{
id: 'name',
label: 'Band name',
isRowHeader: true,
cell: item => (
<CellProfile name={item.name} src={item.image} href={item.website} />
),
},
{
id: 'genre',
label: 'Genre',
cell: item => <CellText title={item.genre} />,
},
{
id: 'yearFormed',
label: 'Year formed',
cell: item => <CellText title={item.yearFormed.toString()} />,
},
{
id: 'albums',
label: 'Albums',
cell: item => <CellText title={item.albums.toString()} />,
},
];
const { tableProps } = useTable({
mode: 'complete',
getData: () => data4,
paginationOptions: { pageSize: 5 },
});
return <Table columnConfig={columns} {...tableProps} />;
},
};
export const EmptyState: Story = {
render: () => {
const columns: ColumnConfig<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => <CellText title={item.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
const { tableProps } = useTable({
mode: 'complete',
getData: () => [],
paginationOptions: { pageSize: 5 },
});
return (
<Table
columnConfig={columns}
{...tableProps}
emptyState={<div>No data available</div>}
/>
);
},
};
export const NoPagination: Story = {
render: () => {
const columns: ColumnConfig<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => <CellText title={item.name} />,
},
{
id: 'owner',
label: 'Owner',
cell: item => <CellText title={item.owner.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
return (
<Table
columnConfig={columns}
data={data1.slice(0, 10)}
pagination={{ type: 'none' }}
/>
);
},
};
export const SelectionWithDisabledRows: Story = {
render: () => {
const [selected, setSelected] = useState<Set<string | number> | 'all'>(
new Set(),
);
const { tableProps } = useTable({
mode: 'complete',
getData: () => selectionData,
paginationOptions: { pageSize: 10 },
});
return (
<Table
{...tableProps}
columnConfig={selectionColumns}
selection={{
mode: 'multiple',
behavior: 'toggle',
selected,
onSelectionChange: setSelected,
}}
rowConfig={{
getIsDisabled: item => 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<Set<string | number> | 'all'>(
new Set(['1', '3']),
);
const [sortDescriptor, setSortDescriptor] = useState<{
column: string;
direction: 'ascending' | 'descending';
}>({ column: 'name', direction: 'ascending' });
const columns: ColumnConfig<CellTextVariantsItem>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
isSortable: true,
cell: item => (
<CellText title={item.name} description={item.description} />
),
},
{
id: 'type',
label: 'Type',
isSortable: true,
cell: item => (
<CellText
title={item.type}
leadingIcon={<span style={{ fontSize: '16px' }}>📦</span>}
/>
),
},
{
id: 'owner',
label: 'Owner',
cell: item => <CellText title={item.owner} href="#" />,
},
];
return (
<Table
columnConfig={columns}
data={cellTextVariantsData}
pagination={{ type: 'none' }}
selection={{
mode: 'multiple',
behavior: 'toggle',
selected,
onSelectionChange: setSelected,
}}
sort={{
descriptor: sortDescriptor,
onSortChange: descriptor =>
setSortDescriptor({
column: String(descriptor.column),
direction: descriptor.direction,
}),
}}
/>
);
},
};
export const LoadingState: Story = {
render: () => {
const columns: ColumnConfig<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => <CellText title={item.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
return (
<Table
columnConfig={columns}
data={undefined}
loading={true}
pagination={{ type: 'none' }}
/>
);
},
};
export const ErrorState: Story = {
render: () => {
const columns: ColumnConfig<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => <CellText title={item.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
return (
<Table
columnConfig={columns}
data={undefined}
error={new Error('Failed to fetch data from the server')}
pagination={{ type: 'none' }}
/>
);
},
};
export const StaleState: Story = {
render: () => {
const columns: ColumnConfig<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => <CellText title={item.name} />,
},
{
id: 'owner',
label: 'Owner',
cell: item => <CellText title={item.owner.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
return (
<Table
columnConfig={columns}
data={data1.slice(0, 5)}
isStale={true}
pagination={{ type: 'none' }}
/>
);
},
};
@@ -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',
@@ -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 => <CellText title={item.name} />,
},
{
id: 'owner',
label: 'Owner',
cell: item => <CellText title={item.owner} />,
},
{ id: 'type', label: 'Type', cell: item => <CellText title={item.type} /> },
];
// Shared meta config for Table stories
export const tableStoriesMeta = {
decorators: [
(Story: () => JSX.Element) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
} satisfies Partial<Meta>;
+87 -2
View File
@@ -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<AriaColumnProps, 'children'> {
export interface ColumnProps extends Omit<ReactAriaColumnProps, 'children'> {
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<T extends TableItem> {
id: string;
label: string;
cell: (item: T) => ReactNode;
header?: () => ReactNode;
isSortable?: boolean;
isHidden?: boolean;
width?: number | string;
isRowHeader?: boolean;
}
/** @public */
export interface RowConfig<T extends TableItem> {
getHref?: (item: T) => string | undefined;
onClick?: (item: T) => void;
getIsDisabled?: (item: T) => boolean;
}
/** @public */
export type RowRenderFn<T extends TableItem> = (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<T extends TableItem> {
columnConfig: readonly ColumnConfig<T>[];
data: T[] | undefined;
loading?: boolean;
isStale?: boolean;
error?: Error;
pagination: TablePaginationType;
sort?: SortState;
rowConfig?: RowConfig<T> | RowRenderFn<T>;
selection?: TableSelection;
emptyState?: ReactNode;
}
@@ -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<typeof TablePagination>;
export const Default = meta.story({
export default meta;
type Story = StoryObj<typeof meta>;
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 (
<TablePagination
{...args}
// setOffset={value => {
// 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,
},
};
@@ -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 (
<div
className={clsx(classNames.root, styles[classNames.root], className)}
{...rest}
>
<div className={clsx(classNames.root, styles[classNames.root])}>
<div className={clsx(classNames.left, styles[classNames.left])}>
{showPageSizeOptions && (
<Select
name="pageSize"
size="small"
aria-label="Select table page size"
placeholder="Show 10 results"
options={[
{ label: 'Show 5 results', value: '5' },
@@ -91,10 +71,9 @@ export function TablePagination(props: TablePaginationProps) {
{ label: 'Show 40 results', value: '40' },
{ label: 'Show 50 results', value: '50' },
]}
selectedKey={pageSize?.toString()}
onSelectionChange={value => {
defaultValue={pageSize.toString()}
onChange={value => {
const newPageSize = Number(value);
setPageSize?.(newPageSize);
onPageSizeChange?.(newPageSize);
}}
className={clsx(classNames.select, styles[classNames.select])}
@@ -102,28 +81,28 @@ export function TablePagination(props: TablePaginationProps) {
)}
</div>
<div className={clsx(classNames.right, styles[classNames.right])}>
<Text
as="p"
variant="body-medium"
>{`${fromCount} - ${toCount} of ${rowCount}`}</Text>
{hasItems && (
<Text as="p" variant="body-medium" id={labelId}>
{label}
</Text>
)}
<ButtonIcon
variant="secondary"
size="small"
onClick={previousPage}
isDisabled={currentOffset === 0}
onClick={onPreviousPage}
isDisabled={!hasPreviousPage}
icon={<RiArrowLeftSLine />}
aria-label="Previous"
aria-label="Previous table page"
aria-describedby={hasItems ? labelId : undefined}
/>
<ButtonIcon
variant="secondary"
size="small"
onClick={nextPage}
isDisabled={
rowCount !== undefined &&
currentOffset + currentPageSize >= rowCount
}
onClick={onNextPage}
isDisabled={!hasNextPage}
icon={<RiArrowRightSLine />}
aria-label="Next"
aria-label="Next table page"
aria-describedby={hasItems ? labelId : undefined}
/>
</div>
</div>
@@ -15,15 +15,19 @@
*/
/** @public */
export interface TablePaginationProps
extends React.HTMLAttributes<HTMLDivElement> {
export interface TablePaginationProps {
pageSize: number;
offset?: number;
pageSize?: number;
setPageSize?: (pageSize: number) => void;
setOffset?: (offset: number) => void;
rowCount?: number;
onNextPage?: () => void;
onPreviousPage?: () => void;
onPageSizeChange?: (pageSize: number) => void;
totalCount?: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
onNextPage: () => void;
onPreviousPage: () => void;
onPageSizeChange?: (size: number) => void;
showPageSizeOptions?: boolean;
getLabel?: (params: {
pageSize: number;
offset?: number;
totalCount?: number;
}) => string;
}