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:
committed by
Johan Persson
parent
3d7fdd786a
commit
243e5e7139
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
+101
@@ -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',
|
||||
+11
File diff suppressed because one or more lines are too long
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user