diff --git a/biome.jsonc b/biome.jsonc
index a1407195..657664d2 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -24,7 +24,11 @@
"linter": {
"enabled": true,
"include": ["app/**/*", "src/**/*", "tests/**/*"],
- "ignore": ["tests/installation/**/*"],
+
+ "ignore": [
+ "src/components/tables/MultiSearch/MultiSearch.tsx", // Ignore for now (need to focus on type errors first)
+ "tests/installation/**/*"
+ ],
"rules": {
"recommended": true,
"complexity": {
@@ -33,7 +37,8 @@
},
"style": {
"useImportType": "off",
- "noUnusedTemplateLiteral": "off"
+ "noUnusedTemplateLiteral": "off",
+ "noUselessElse": "off"
}
}
}
diff --git a/package-lock.json b/package-lock.json
index 847c111e..d17716de 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,11 +20,13 @@
"react-dom": "^19.0.0",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
+ "react-table": "^7.8.0",
"react-toastify": "^10.0.6"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@chromatic-com/storybook": "^3.2.3",
+ "@ngneat/falso": "^6.4.0",
"@percy/cli": "^1.30.6",
"@percy/storybook": "^6.0.3",
"@storybook/addon-a11y": "^8.4.7",
@@ -41,6 +43,7 @@
"@types/node": "^22.10.5",
"@types/react": "^19.0.4",
"@types/react-dom": "^19.0.2",
+ "@types/react-table": "^7.7.20",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/ui": "^2.1.8",
"axe-playwright": "^2.0.3",
@@ -941,6 +944,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dev": true,
+ "license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
@@ -2371,6 +2375,27 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/@ngneat/falso": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/@ngneat/falso/-/falso-6.4.0.tgz",
+ "integrity": "sha512-f6r036h2fX/AoHw1eV2t8+qWQwrbSrozs3zXMhhwoO7SJBc+DGMxRWEhFeYIinfwx0uhUH8ggx5+PDLzYESLOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "seedrandom": "3.0.5",
+ "uuid": "8.3.2"
+ }
+ },
+ "node_modules/@ngneat/falso/node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -4625,6 +4650,15 @@
"@types/react": "^19.0.0"
}
},
+ "node_modules/@types/react-table": {
+ "version": "7.7.20",
+ "resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.20.tgz",
+ "integrity": "sha512-ahMp4pmjVlnExxNwxyaDrFgmKxSbPwU23sGQw2gJK4EhCvnvmib2s/O/+y1dfV57dXOwpr2plfyBol+vEHbi2w==",
+ "dev": true,
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/resolve": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
@@ -14484,6 +14518,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-table": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.8.0.tgz",
+ "integrity": "sha512-hNaz4ygkZO4bESeFfnfOft73iBUj8K5oKi1EcSHPAibEydfsX2MyU6Z8KCr3mv3C9Kqqh71U+DhZkFvibbnPbA==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.3 || ^17.0.0-0 || ^18.0.0"
+ }
+ },
"node_modules/react-toastify": {
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.6.tgz",
@@ -15492,6 +15538,13 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="
},
+ "node_modules/seedrandom": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
+ "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
diff --git a/package.json b/package.json
index 770a9860..86bea832 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,7 @@
"check:types": "tsc -b",
"lint:style": "stylelint 'src/**/*.scss'",
"lint:script": "biome lint",
- "lint": "npm run lint:style && npm run lint:script",
+ "lint": "npm run lint:style; npm run lint:script",
"test": "npm run check:types && npm run lint:style",
"test-ui": "vitest --ui",
"coverage": "vitest run --coverage",
@@ -101,7 +101,9 @@
"sass-embedded": "^1.83.1",
"lightningcss": "^1.29.1",
"@types/react": "^19.0.4",
- "@types/react-dom": "^19.0.2"
+ "@types/react-dom": "^19.0.2",
+ "@types/react-table": "^7.7.20",
+ "@ngneat/falso": "^6.4.0"
},
"dependencies": {
"date-fns": "^4.1.0",
@@ -112,6 +114,7 @@
"react-error-boundary": "^5.0.0",
"@floating-ui/react": "^0.26.28",
"react-toastify": "^10.0.6",
+ "react-table": "^7.8.0",
"react-datepicker": "^7.6.0",
"effect": "^3.12.1",
"react-hook-form": "^7.54.2",
@@ -134,6 +137,10 @@
"react-datepicker": {
"react": "$react",
"react-dom": "$react-dom"
+ },
+ "react-table": {
+ "react": "$react",
+ "react-dom": "$react-dom"
}
}
}
diff --git a/package.json.js b/package.json.js
index 15c2995c..f80895b9 100644
--- a/package.json.js
+++ b/package.json.js
@@ -74,7 +74,7 @@ const packageConfig = {
'check:types': 'tsc -b',
'lint:style': `stylelint 'src/**/*.scss'`,
'lint:script': 'biome lint',
- 'lint': 'npm run lint:style && npm run lint:script',
+ 'lint': 'npm run lint:style; npm run lint:script',
// Test
// Note: use `vitest run --root=. src/...` to run a single test file
@@ -155,6 +155,11 @@ const packageConfig = {
// React
'@types/react': '^19.0.4',
'@types/react-dom': '^19.0.2',
+
+ // Data table
+ '@types/react-table': '^7.7.20',
+ // Fake data
+ "@ngneat/falso": "^6.4.0",
},
// Dependencies needed when running the generated build
@@ -172,6 +177,7 @@ const packageConfig = {
'@floating-ui/react': '^0.26.28',
'react-toastify': '^10.0.6',
+ 'react-table': '^7.8.0',
'react-datepicker': '^7.6.0',
'effect': '^3.12.1',
@@ -200,6 +206,11 @@ const packageConfig = {
'react': '$react',
'react-dom': '$react-dom',
},
+ // TODO: Revisit after updating react-table to v8
+ 'react-table': {
+ 'react': '$react',
+ 'react-dom': '$react-dom',
+ },
},
};
@@ -229,4 +240,4 @@ const makePackageJson = () => {
};
// Write to `package.json`
-fs.writeFileSync('./package.json', JSON.stringify(makePackageJson(), null, 2) + '\n');
+fs.writeFileSync('./package.json', `${JSON.stringify(makePackageJson(), null, 2)}\n`);
diff --git a/src/components/actions/Button/Button.module.scss b/src/components/actions/Button/Button.module.scss
index 58d1c6a3..457e8a9c 100644
--- a/src/components/actions/Button/Button.module.scss
+++ b/src/components/actions/Button/Button.module.scss
@@ -10,23 +10,23 @@
--bk-button-color-accent: #{bk.$theme-button-primary-background-default};
--bk-button-color-contrast: #{bk.$theme-button-primary-text-default};
-
- align-self: flex-start;
-
+
cursor: pointer;
user-select: none;
margin: 0;
- padding: 0;
- &:not(.bk-button--trimmed) {
- padding: calc(bk.$spacing-2 - 0.125lh) bk.$spacing-3; /* Note: compensate for line-height difference with Figma */
- }
+ padding: calc(bk.$spacing-2 - 0.125lh) bk.$spacing-3; /* Note: compensate for line-height difference with Figma */
/* Transparent border for consistency with other variants that have a border */
border: bk.$size-1 solid transparent;
border-radius: bk.$radius-s;
background: transparent;
+ &.bk-button--trimmed {
+ padding: 0;
+ border: none;
+ }
+
display: inline-flex;
flex-flow: row wrap;
align-items: center;
diff --git a/src/components/actions/Button/Button.tsx b/src/components/actions/Button/Button.tsx
index a990ec51..a09c1f4c 100644
--- a/src/components/actions/Button/Button.tsx
+++ b/src/components/actions/Button/Button.tsx
@@ -147,8 +147,9 @@ export const Button = (props: ButtonProps) => {
[cl['bk-button--trimmed']]: trimmed,
[cl['bk-button--primary']]: variant === 'primary',
[cl['bk-button--secondary']]: variant === 'secondary',
- [cl['bk-button--nonactive']]: isNonactive,
[cl['bk-button--disabled']]: !isInteractive,
+ [cl['bk-button--nonactive']]: isNonactive,
+ 'nonactive': isNonactive, // Global class name so that consumers can style nonactive states
}, props.className)}
onClick={handleClick}
>
diff --git a/src/components/forms/controls/Input/Input.tsx b/src/components/forms/controls/Input/Input.tsx
index 2aa17087..57548074 100644
--- a/src/components/forms/controls/Input/Input.tsx
+++ b/src/components/forms/controls/Input/Input.tsx
@@ -27,15 +27,13 @@ export const Input = ({ unstyled = false, type = 'text', ...propsRest }: InputPr
}
return (
-
-
-
+
);
};
diff --git a/src/components/forms/fields/CheckboxField/CheckboxField.tsx b/src/components/forms/fields/CheckboxField/CheckboxField.tsx
index 2e5f9567..5324bd08 100644
--- a/src/components/forms/fields/CheckboxField/CheckboxField.tsx
+++ b/src/components/forms/fields/CheckboxField/CheckboxField.tsx
@@ -42,7 +42,7 @@ export const CheckboxFieldTitle = ({ className, children, titleOptional, titleTo
);
-export type CheckboxFieldProps = ComponentProps<'div'> & {
+export type CheckboxFieldProps = ComponentProps & {
/** Whether this component should be unstyled. */
unstyled?: undefined | boolean,
@@ -71,7 +71,7 @@ export type CheckboxFieldProps = ComponentProps<'div'> & {
disabled?: undefined | boolean,
/** The onChange event for the checkbox. Passed down to Checkbox component. */
- onChange?: (e: React.FormEvent) => void,
+ onChange?: (event: React.ChangeEvent) => void,
};
/**
@@ -86,6 +86,7 @@ export const CheckboxField = (props: CheckboxFieldProps) => {
titleOptional,
titleTooltip,
className,
+ ...propsRest
} = props;
return (
@@ -109,6 +110,7 @@ export const CheckboxField = (props: CheckboxFieldProps) => {
defaultChecked={props.defaultChecked}
disabled={props.disabled}
onChange={props.onChange}
+ {...propsRest}
/>
{label}
diff --git a/src/components/forms/fields/CheckboxGroup/CheckboxGroup.tsx b/src/components/forms/fields/CheckboxGroup/CheckboxGroup.tsx
index 23966396..b1e117ae 100644
--- a/src/components/forms/fields/CheckboxGroup/CheckboxGroup.tsx
+++ b/src/components/forms/fields/CheckboxGroup/CheckboxGroup.tsx
@@ -2,7 +2,7 @@
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-import { classNames as cx } from '../../../../util/componentUtil.ts';
+import { classNames as cx, type ComponentProps } from '../../../../util/componentUtil.ts';
import * as React from 'react';
import cl from './CheckboxGroup.module.scss';
@@ -12,9 +12,9 @@ import { CheckboxField } from '../CheckboxField/CheckboxField.tsx';
export { cl as CheckboxGroupClassNames };
-export type CheckboxGroupProps = React.PropsWithChildren<{
- direction?: undefined | "vertical" | "horizontal";
-}>;
+export type CheckboxGroupProps = ComponentProps<'div'> & {
+ direction?: undefined | 'vertical' | 'horizontal',
+};
/**
* Checkbox group component, wrapping multiple CheckboxField components vertically or horizontally.
diff --git a/src/components/graphics/Icon/Icon.tsx b/src/components/graphics/Icon/Icon.tsx
index 737aaf23..884b9dba 100644
--- a/src/components/graphics/Icon/Icon.tsx
+++ b/src/components/graphics/Icon/Icon.tsx
@@ -9,9 +9,14 @@ import { icons } from '../../../assets/icons/_icons.ts';
import cl from './Icon.module.scss';
-export type IconName = keyof typeof icons;
export { cl as IconClassNames };
+export type IconName = keyof typeof icons;
+export const iconNames = new Set(Object.keys(icons) as Array);
+export const isIconName = (iconName: string): iconName is IconName => {
+ return (iconNames as Set).has(iconName);
+};
+
export type Decoration = (
| { type: 'background-circle' }
);
diff --git a/src/components/tables/DataTable/DataTableContext.tsx b/src/components/tables/DataTable/DataTableContext.tsx
new file mode 100644
index 00000000..62335ea0
--- /dev/null
+++ b/src/components/tables/DataTable/DataTableContext.tsx
@@ -0,0 +1,34 @@
+/* Copyright (c) Fortanix, Inc.
+|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
+|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import * as React from 'react';
+import type * as ReactTable from 'react-table';
+
+
+export type DataTableStatus = {
+ ready: boolean, // Whether the data is ready to be used/shown in the UI
+ loading: boolean, // Whether we're (re)loading the data
+ error: null | Error, // Whether the last loading attempt resulted in an error
+};
+
+export type TableContextState = {
+ status: DataTableStatus,
+ setStatus: (status: DataTableStatus) => void,
+ reload: () => void,
+ table: ReactTable.TableInstance,
+};
+
+const TableContext = React.createContext>(null); // Memoized
+export const createTableContext = () => TableContext as React.Context>;
+
+
+export const useTable = (): TableContextState => {
+ const context = React.useContext(TableContext as React.Context>);
+
+ if (context === null) {
+ throw new TypeError('TableContext not yet initialized');
+ }
+
+ return context;
+};
diff --git a/src/components/tables/DataTable/DataTableEager.module.scss b/src/components/tables/DataTable/DataTableEager.module.scss
new file mode 100644
index 00000000..04a4703f
--- /dev/null
+++ b/src/components/tables/DataTable/DataTableEager.module.scss
@@ -0,0 +1,16 @@
+/* Copyright (c) Fortanix, Inc.
+|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
+|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@use '../../../styling/defs.scss' as bk;
+@use './DataTableLazy.module.scss' as dataTableLazy;
+
+@layer baklava.components {
+ .bk-data-table-eager {
+ @include bk.component-base(bk-data-table-eager);
+
+ &.bk-data-table-eager--loading {
+ @include dataTableLazy.data-table-loading;
+ }
+ }
+}
diff --git a/src/components/tables/DataTable/DataTableEager.stories.scss b/src/components/tables/DataTable/DataTableEager.stories.scss
new file mode 100644
index 00000000..0733e807
--- /dev/null
+++ b/src/components/tables/DataTable/DataTableEager.stories.scss
@@ -0,0 +1,7 @@
+/* Copyright (c) Fortanix, Inc.
+|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
+|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.user-table__column{
+ width: 25%;
+}
diff --git a/src/components/tables/DataTable/DataTableEager.stories.tsx b/src/components/tables/DataTable/DataTableEager.stories.tsx
new file mode 100644
index 00000000..9436c8da
--- /dev/null
+++ b/src/components/tables/DataTable/DataTableEager.stories.tsx
@@ -0,0 +1,263 @@
+/* Copyright (c) Fortanix, Inc.
+|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
+|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { differenceInDays } from 'date-fns';
+import * as React from 'react';
+
+import { useEffectAsync } from '../../../util/reactUtil.ts';
+import { delay } from '../util/async_util.ts';
+import { type User, generateData } from '../util/generateData.ts';
+import { sortDateTime } from '../util/sorting_util.ts';
+import * as Filtering from './filtering/Filtering.ts';
+import type { Fields, FilterQuery } from '../MultiSearch/filterQuery.ts';
+
+import { Panel } from '../../containers/Panel/Panel.tsx';
+import * as DataTablePlugins from './plugins/useRowSelectColumn.tsx';
+import * as DataTableEager from './DataTableEager.tsx';
+
+import './DataTableEager.stories.scss';
+
+
+const columns = [
+ {
+ id: 'name',
+ accessor: (user: User) => user.name,
+ Header: 'Name',
+ Cell: ({ value }: { value: string }) => value,
+ disableSortBy: false,
+ disableGlobalFilter: false,
+ className: 'user-table__column',
+ },
+ {
+ id: 'email',
+ accessor: (user: User) => user.email,
+ Header: 'Email',
+ disableSortBy: false,
+ disableGlobalFilter: false,
+ className: 'user-table__column',
+ },
+ {
+ id: 'company',
+ accessor: (user: User) => user.company,
+ Header: 'Company',
+ disableSortBy: false,
+ disableGlobalFilter: false,
+ className: 'user-table__column',
+ },
+ {
+ id: 'joinDate',
+ accessor: (user: User) => user.joinDate,
+ Header: 'Joined',
+ Cell: ({ value }: { value: Date }) =>
+ value.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
+ disableSortBy: false,
+ sortType: sortDateTime,
+ disableGlobalFilter: false,
+ className: 'user-table__column',
+ },
+];
+
+const fields: Fields = {
+ name: {
+ type: 'text',
+ operators: ['$text'],
+ label: 'Name',
+ placeholder: 'Search name',
+ },
+ email: {
+ type: 'text',
+ operators: ['$text'],
+ label: 'Email',
+ placeholder: 'Search email',
+ },
+ company: {
+ type: 'text',
+ operators: ['$text'],
+ label: 'Company',
+ placeholder: 'Search company',
+ },
+ joinDate: {
+ type: 'datetime',
+ operators: ['$lt', '$lte', '$gt', '$gte', '$range'],
+ label: 'Joined',
+ placeholder: 'Search by joined date',
+ },
+ daysActive: {
+ type: 'number',
+ operators: ['$eq', '$ne', '$lt', '$lte', '$gt', '$gte'],
+ label: 'Days active',
+ placeholder: 'Number of days active',
+ accessor: (item: unknown) => differenceInDays(new Date(), (item as User).joinDate),
+ },
+};
+
+type dataTeableEagerTemplateProps = DataTableEager.TableProviderEagerProps & { delay: number };
+
+const DataTableEagerTemplate = (props: dataTeableEagerTemplateProps) => {
+ const memoizedColumns = React.useMemo(() => props.columns, [props.columns]);
+ const memoizedItems = React.useMemo(() => props.items, [props.items]);
+
+ const [isReady, setIsReady] = React.useState(props.isReady ?? true);
+
+ useEffectAsync(async () => {
+ if (typeof props.delay !== 'number' && isReady === false) return;
+ await delay(props.delay);
+ setIsReady(true);
+ }, [props.delay]);
+
+ return (
+
+ item.id}
+ plugins={[DataTablePlugins.useRowSelectColumn]}
+ >
+
+
+
+
+ );
+};
+
+// Template: Table with Filtering
+const DataTableEagerWithFilterTemplate = (props: dataTeableEagerTemplateProps) => {
+ const memoizedColumns = React.useMemo(() => props.columns, [props.columns]);
+
+ const [filters, setFilters] = React.useState([]);
+ const [filteredItems, setFilteredItems] = React.useState(props.items as User[]);
+
+ // Convert items array into a record
+ const itemsAsRecord = React.useMemo(() => {
+ return Object.fromEntries(props.items.map(item => [item.id, item])) as Record;
+ }, [props.items]);
+
+ React.useEffect(() => {
+ const filtered = Filtering.filterByQuery(fields, itemsAsRecord, filters);
+ setFilteredItems(Object.values(filtered) as User[]);
+ }, [filters, itemsAsRecord]);
+
+ return (
+
+ item.id}
+ plugins={[DataTablePlugins.useRowSelectColumn]}
+ >
+
+
+
+ );
+};
+
+export default {
+ component: DataTableEager.DataTableEager,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+};
+
+// Stories
+export const Empty = {
+ args: {
+ columns,
+ items: generateData({ numItems: 0 }),
+ },
+ render: (args: dataTeableEagerTemplateProps) => ,
+};
+
+export const SinglePage = {
+ args: {
+ columns,
+ items: generateData({ numItems: 5 }),
+ },
+ render: (args: dataTeableEagerTemplateProps) => ,
+};
+
+export const MultiplePagesSmall = {
+ args: {
+ columns,
+ items: generateData({ numItems: 45 }),
+ },
+ render: (args: dataTeableEagerTemplateProps) => ,
+};
+
+export const MultiplePagesLarge = {
+ args: {
+ columns,
+ items: generateData({ numItems: 1000 }),
+ },
+ render: (args: dataTeableEagerTemplateProps) => ,
+};
+
+export const AsyncInitialization = {
+ args: {
+ columns,
+ items: generateData({ numItems: 1000 }),
+ delay: 1500,
+ isReady: false,
+ },
+ render: (args: dataTeableEagerTemplateProps) => ,
+};
+
+// export const WithFilter = {
+// args: {
+// columns,
+// items: generateData({ numItems: 45 }),
+// },
+// render: (args: dataTeableEagerTemplateProps) => ,
+// };
+
+const moreColumns = [
+ ...columns,
+ {
+ id: 'dummy_1',
+ accessor: (user: User) => user.name,
+ Header: 'Name',
+ Cell: ({ value }: { value: string }) => value,
+ disableSortBy: false,
+ disableGlobalFilter: true,
+ },
+ {
+ id: 'dummy_2',
+ accessor: (user: User) => user.email,
+ Header: 'Email',
+ disableSortBy: false,
+ disableGlobalFilter: true,
+ },
+ {
+ id: 'dummy_3',
+ accessor: (user: User) => user.company,
+ Header: 'Company',
+ disableSortBy: false,
+ disableGlobalFilter: true,
+ },
+ {
+ id: 'dummy_4',
+ accessor: (user: User) => user.company,
+ Header: 'Company',
+ disableSortBy: false,
+ disableGlobalFilter: true,
+ },
+ {
+ id: 'dummy_5',
+ accessor: (user: User) => user.company,
+ Header: 'Company',
+ disableSortBy: false,
+ disableGlobalFilter: true,
+ },
+];
+// FIXME: example with horizontal scroll
+// export const WithScroll = {
+// args: {
+// columns: moreColumns,
+// items: generateData({ numItems: 45 }),
+// },
+// render: (args: dataTeableEagerTemplateProps) => ,
+// };
diff --git a/src/components/tables/DataTable/DataTableEager.tsx b/src/components/tables/DataTable/DataTableEager.tsx
new file mode 100644
index 00000000..5aa62fbc
--- /dev/null
+++ b/src/components/tables/DataTable/DataTableEager.tsx
@@ -0,0 +1,177 @@
+/* Copyright (c) Fortanix, Inc.
+|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
+|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import * as React from 'react';
+import { classNames as cx, type ClassNameArgument } from '../../../util/componentUtil.ts';
+import * as ReactTable from 'react-table';
+
+import { type TableContextState, createTableContext, useTable } from './DataTableContext.tsx';
+import { Pagination } from './pagination/Pagination.tsx';
+import { SearchInput } from '../SearchInput/SearchInput.tsx';
+import { MultiSearch as MultiSearchInput } from '../MultiSearch/MultiSearch.tsx';
+import { DataTableSync } from './table/DataTable.tsx';
+
+import cl from './DataTableEager.module.scss';
+
+export * from './DataTableContext.tsx';
+export { Pagination };
+export { DataTablePlaceholderEmpty, DataTablePlaceholderError } from './table/DataTablePlaceholder.tsx';
+
+
+interface ReactTableOptions extends ReactTable.TableOptions {
+ // Add custom properties here
+ //onClick?: (row: ReactTable.Row) => void,
+}
+
+export type TableProviderEagerProps = {
+ children: React.ReactNode,
+ columns: ReactTableOptions['columns'],
+ items: ReactTableOptions['data'],
+ getRowId: ReactTableOptions['getRowId'],
+ plugins?: Array>,
+ initialState?: Partial>,
+ isReady?: boolean,
+};
+export const TableProviderEager = (props: TableProviderEagerProps) => {
+ const {
+ children,
+ columns,
+ items,
+ getRowId,
+ plugins = [],
+ initialState = {},
+ isReady = true,
+ } = props;
+
+ const tableOptions: ReactTable.TableOptions = {
+ columns,
+ data: items,
+ ...(getRowId && { getRowId }),
+ };
+ const table = ReactTable.useTable(
+ {
+ ...tableOptions,
+ defaultColumn: {
+ disableGlobalFilter: true,
+ disableSortBy: true,
+ },
+
+ initialState: {
+ // useSortBy
+ sortBy: [{ id: 'name', desc: false }],
+
+ // useGlobalFilter
+ globalFilter: '',
+
+ // useFilters
+ filters: [],
+
+ // usePagination
+ pageSize: 10,
+ pageIndex: 0,
+
+ ...initialState,
+ },
+
+ // useGlobalFilter
+ manualGlobalFilter: false,
+
+ // useSortBy
+ disableSortRemove: true,
+
+ // usePagination
+ manualPagination: false,
+ autoResetPage: false, // Do not automatically reset to first page if the data changes
+ },
+ ReactTable.useGlobalFilter,
+ ReactTable.useFilters,
+ ReactTable.useSortBy,
+ ReactTable.usePagination,
+ ReactTable.useRowSelect,
+ ...plugins,
+ );
+
+ // Note: the `table` reference is mutated, so cannot use it as dependency for `useMemo` directly
+ // biome-ignore lint/correctness/useExhaustiveDependencies:
+ const context = React.useMemo>(() => ({
+ status: { ready: isReady, loading: false, error: null },
+ setStatus() {},
+ reload() {},
+ table,
+ }), [table.state, ...Object.values(tableOptions)]);
+
+ const TableContext = React.useMemo(() => createTableContext(), []);
+
+ return (
+
+ {children}
+
+ );
+};
+TableProviderEager.displayName = 'TableProviderEager';
+
+
+export const Search = (props: React.ComponentPropsWithoutRef) => {
+ const { table } = useTable();
+
+ return (
+ { table.setGlobalFilter(evt.target.value); }}
+ {...props}
+ />
+ );
+};
+Search.displayName = 'Search';
+
+export const MultiSearch = (props: React.ComponentPropsWithoutRef) => {
+ const { table } = useTable();
+
+ return (
+ { table.setCustomFilters(filters); }}
+ {...props}
+ />
+ );
+};
+MultiSearch.displayName = 'MultiSearch';
+
+export type DataTableEagerProps = Omit, 'table'> & {
+ children?: React.ReactNode,
+ className?: ClassNameArgument,
+ footer?: React.ReactNode,
+};
+export const DataTableEager = ({ children, className, footer, ...propsRest }: DataTableEagerProps) => {
+ const { table, status } = useTable();
+
+ React.useEffect(() => {
+ if (table.page.length === 0 && table.state.pageIndex > 0 && table.canPreviousPage) {
+ // Edge case: no items and yet we are not on the first page. Navigate back to the previous page.
+ table.previousPage();
+ }
+ }, [table.page.length, table.state.pageIndex, table.canPreviousPage, table.previousPage]);
+
+ // Use `` by default, unless the table is empty (in which case there are "zero" pages)
+ const footerDefault = status.ready && table.rows.length > 0 ? : null;
+ const footerWithFallback = typeof footer === 'undefined' ? footerDefault : footer;
+
+ return (
+
+ {children}
+
+ );
+};
+DataTableEager.displayName = 'DataTableEager';
diff --git a/src/components/tables/DataTable/DataTableLazy.module.scss b/src/components/tables/DataTable/DataTableLazy.module.scss
new file mode 100644
index 00000000..0c3cdf4d
--- /dev/null
+++ b/src/components/tables/DataTable/DataTableLazy.module.scss
@@ -0,0 +1,30 @@
+/* Copyright (c) Fortanix, Inc.
+|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
+|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@use '../../../styling/defs.scss' as bk;
+
+
+@mixin data-table-loading {
+ position: relative;
+
+ .table-spinner {
+ position: absolute;
+ top: calc(50% - (80px / 2));
+ left: calc(50% - (80px / 2));
+ }
+
+ .bk-data-table__table tbody {
+ opacity: 0.6;
+ }
+}
+
+@layer baklava.components {
+ .bk-data-table-lazy {
+ @include bk.component-base(bk-data-table-lazy);
+
+ &.bk-data-table-lazy--loading {
+ @include data-table-loading;
+ }
+ }
+}
diff --git a/src/components/tables/DataTable/DataTableLazy.stories.tsx b/src/components/tables/DataTable/DataTableLazy.stories.tsx
new file mode 100644
index 00000000..d64740be
--- /dev/null
+++ b/src/components/tables/DataTable/DataTableLazy.stories.tsx
@@ -0,0 +1,176 @@
+/* Copyright (c) Fortanix, Inc.
+|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
+|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import * as React from 'react';
+
+import { delay } from '../util/async_util.ts';
+import { type User, generateData } from '../util/generateData.ts';
+import { sortDateTime } from '../util/sorting_util.ts';
+
+import { Button } from '../../actions/Button/Button.tsx';
+import { Panel } from '../../containers/Panel/Panel.tsx';
+import * as DataTableLazy from './DataTableLazy.tsx';
+
+export default {
+ component: DataTableLazy.DataTableLazy,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+};
+
+type dataTeableLazyTemplateProps = DataTableLazy.TableProviderLazyProps & { delay: number, items: Array };
+const DataTableLazyTemplate = (props: dataTeableLazyTemplateProps) => {
+ const columns = React.useMemo(() => props.columns, [props.columns]);
+ const items = React.useMemo(() => props.items, [props.items]);
+ const delayQuery = props.delay ?? null;
+
+ const [itemsProcessed, setItemsProcessed] = React.useState>({
+ total: 0,
+ itemsPage: [],
+ });
+
+ const query: DataTableLazy.DataTableQuery = React.useCallback(
+ async ({ pageIndex, pageSize }) => {
+ if (delayQuery === Number.POSITIVE_INFINITY) return new Promise(() => {}); // Infinite delay
+ if (delayQuery === -1) throw new Error('Failed'); // Simulate failure
+
+ if (delayQuery) await delay(delayQuery);
+
+ // Simulate failure on page 4
+ if (typeof delayQuery === 'number' && delayQuery > 0 && pageIndex + 1 === 4) {
+ throw new Error('Failed');
+ }
+
+ const itemsProcessedPage = items.slice(pageIndex * pageSize, pageIndex * pageSize + pageSize);
+
+ return { total: items.length, itemsPage: itemsProcessedPage };
+ },
+ [items, delayQuery]
+ );
+
+ return (
+
+
+
+
+
+
+
+ }
+ />
+ }
+ />
+
+
+ );
+};
+
+// Column definitions
+const columns = [
+ {
+ id: 'name',
+ accessor: (user: User) => user.name,
+ Header: 'Name',
+ Cell: ({ value }: { value: string }) => value,
+ disableSortBy: false,
+ disableGlobalFilter: false,
+ },
+ {
+ id: 'email',
+ accessor: (user: User) => user.email,
+ Header: 'Email',
+ disableSortBy: false,
+ disableGlobalFilter: false,
+ },
+ {
+ id: 'company',
+ accessor: (user: User) => user.company,
+ Header: 'Company',
+ disableSortBy: false,
+ disableGlobalFilter: false,
+ },
+ {
+ id: 'joinDate',
+ accessor: (user: User) => user.joinDate,
+ Header: 'Joined',
+ Cell: ({ value }: { value: Date }) =>
+ value.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
+ sortType: sortDateTime,
+ disableSortBy: false,
+ disableGlobalFilter: false,
+ },
+];
+
+// Stories
+export const Empty = {
+ args: {
+ columns,
+ items: generateData({ numItems: 0 }),
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
+
+export const SinglePage = {
+ args: {
+ columns,
+ items: generateData({ numItems: 10 }),
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
+
+export const MultiplePagesSmall = {
+ args: {
+ columns,
+ items: generateData({ numItems: 45 }),
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
+
+export const MultiplePagesLarge = {
+ args: {
+ columns,
+ items: generateData({ numItems: 1000 }),
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
+
+export const SlowNetwork = {
+ args: {
+ columns,
+ items: generateData({ numItems: 1000 }),
+ delay: 1500,
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
+
+export const InfiniteDelay = {
+ args: {
+ columns,
+ items: generateData({ numItems: 50 }),
+ delay: Number.POSITIVE_INFINITY,
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
+
+export const StatusFailure = {
+ args: {
+ columns,
+ items: generateData({ numItems: 1000 }),
+ delay: -1,
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
diff --git a/src/components/tables/DataTable/DataTableLazy.tsx b/src/components/tables/DataTable/DataTableLazy.tsx
new file mode 100644
index 00000000..38c7beb0
--- /dev/null
+++ b/src/components/tables/DataTable/DataTableLazy.tsx
@@ -0,0 +1,299 @@
+/* Copyright (c) Fortanix, Inc.
+|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
+|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import * as React from 'react';
+import { classNames as cx } from '../../../util/componentUtil.ts';
+import { useEffectAsync } from '../../../util/reactUtil.ts';
+
+import { Spinner } from '../../graphics/Spinner/Spinner.tsx';
+import { PlaceholderEmptyAction } from '../../graphics/PlaceholderEmpty/PlaceholderEmpty.tsx';
+import { Button } from '../../actions/Button/Button.tsx';
+
+import * as ReactTable from 'react-table';
+import { type DataTableStatus, type TableContextState, createTableContext, useTable } from './DataTableContext.tsx';
+import { Pagination } from './pagination/Pagination';
+import { DataTablePlaceholderError } from './table/DataTablePlaceholder';
+import { DataTableAsync } from './table/DataTable';
+
+import cl from './DataTableLazy.module.scss';
+
+
+export * from './DataTableContext.tsx';
+export { Pagination };
+export { DataTablePlaceholderEmpty, DataTablePlaceholderError } from './table/DataTablePlaceholder';
+export { PlaceholderEmptyAction } from '../../graphics/PlaceholderEmpty/PlaceholderEmpty.tsx';
+
+export { Search, MultiSearch } from './DataTableEager'; // FIXME: move to a common module
+
+export interface ReactTableOptions extends ReactTable.TableOptions {
+ // Add custom properties here
+ //onClick?: (row: ReactTable.Row) => void,
+}
+
+export type DataTableQueryResult = { total: number, itemsPage: ReactTableOptions['data'] };
+export type DataTableQuery =
+ (params: {
+ pageIndex: number,
+ pageSize: number,
+ offset: number,
+ limit: number,
+ sortBy: Array>,
+ orderings: Array<{ column: string, direction: 'ASC' | 'DESC' }>,
+ globalFilter: ReactTable.UseGlobalFiltersState['globalFilter'],
+ filters: ReactTable.Filters,
+ }) => Promise>;
+
+
+type UseQueryParams = {
+ table: ReactTable.TableInstance,
+ query: DataTableQuery,
+ status: DataTableStatus,
+ setStatus: React.Dispatch>,
+ handleResult: (result: DataTableQueryResult) => void,
+};
+const useQuery = (
+ { table, query, status, setStatus, handleResult }: UseQueryParams,
+ deps: Array = [],
+) => {
+ // Keep track of the latest query being performed
+ const latestQuery = React.useRef>>(null);
+
+ useEffectAsync(async () => {
+ try {
+ setStatus(status => ({ ...status, loading: true, error: null }));
+
+ const queryPromise = query({
+ pageIndex: table.state.pageIndex,
+ pageSize: table.state.pageSize,
+ offset: table.state.pageIndex * table.state.pageSize,
+ limit: table.state.pageSize,
+ sortBy: table.state.sortBy,
+ orderings: table.state.sortBy.map(({ id, desc }) =>
+ ({ column: id, direction: desc ? 'DESC' : 'ASC' }),
+ ),
+ globalFilter: table.state.globalFilter,
+ filters: table.state.filters,
+ });
+ latestQuery.current = queryPromise;
+ const queryResult = await queryPromise;
+
+ // Note: only update if we haven't been "superseded" by a more recent update
+ if (latestQuery.current !== null && latestQuery.current === queryPromise) {
+ setStatus({ ready: true, loading: false, error: null });
+ handleResult(queryResult);
+ }
+ } catch (reason: unknown) {
+ console.error(reason);
+ const error = reason instanceof Error ? reason : new Error('Unknown error');
+ setStatus({ ready: false, loading: false, error });
+ //handleResult({ total: 0, itemsPage: [] });
+ }
+ }, [
+ setStatus,
+ query,
+ table.state.pageIndex,
+ table.state.pageSize,
+ table.state.sortBy,
+ table.state.globalFilter,
+ table.state.filters,
+ ...deps,
+ ]);
+};
+
+export type TableProviderLazyProps = {
+ children: React.ReactNode,
+ columns: ReactTableOptions['columns'],
+ getRowId: ReactTableOptions['getRowId'],
+ plugins?: Array>,
+ initialState: Partial>,
+
+ // Callback to query a new set of items
+ query: DataTableQuery,
+
+ // Controlled state
+ items: DataTableQueryResult,
+ // Callback to request the consumer to update controlled state with the given data
+ updateItems: (items: DataTableQueryResult) => void,
+};
+export const TableProviderLazy = (props: TableProviderLazyProps) => {
+ const {
+ children,
+ columns,
+ getRowId,
+ plugins = [],
+ initialState,
+ query,
+ items,
+ updateItems,
+ } = props;
+
+ // Status
+ const [status, setStatus] = React.useState({ ready: false, loading: false, error: null });
+
+ // Reload
+ const [reloadTrigger, setReloadTrigger] = React.useState(0);
+ const reload = React.useCallback(() => {
+ setReloadTrigger(trigger => (trigger + 1) % 100);
+ }, [setReloadTrigger]);
+
+
+ // Controlled table state
+ const [pageSize, setPageSize] = React.useState(initialState?.pageSize ?? 10);
+
+ const tableOptions: ReactTable.TableOptions = {
+ columns,
+ data: items.itemsPage,
+ ...(getRowId && { getRowId }), // Add `getRowId` only if it is defined
+ };
+ const table = ReactTable.useTable(
+ {
+ ...tableOptions,
+
+ defaultColumn: {
+ disableGlobalFilter: true,
+ disableSortBy: true,
+ },
+
+ initialState: {
+ // useSortBy
+ sortBy: [{ id: 'name', desc: false }],
+
+ // useGlobalFilter
+ globalFilter: '',
+
+ // useFilters
+ filters: [],
+
+ // usePagination
+ pageSize,
+ pageIndex: 0,
+
+ ...initialState,
+ },
+ stateReducer: (state, action, prevState, instance) => {
+ if (action.type === 'setPageSize') {
+ setPageSize(action.pageSize);
+ }
+
+ return state;
+ },
+ useControlledState: state => {
+ return React.useMemo(
+ () => ({
+ ...state,
+ pageSize,
+ }),
+ [state, pageSize],
+ );
+ },
+
+ // https://react-table.tanstack.com/docs/faq
+ // #how-do-i-stop-my-table-state-from-automatically-resetting-when-my-data-changes
+ autoResetPage: false,
+ autoResetExpanded: false,
+ autoResetGroupBy: false,
+ autoResetSelectedRows: false,
+ autoResetSortBy: false,
+ autoResetFilters: false,
+ autoResetGlobalFilter: false,
+ autoResetRowState: false,
+
+ // useGlobalFilter
+ manualGlobalFilter: true,
+
+ // useFilters
+ manualFilters: true,
+
+ // useSortBy
+ manualSortBy: true,
+ disableSortRemove: true,
+
+ // usePagination
+ manualPagination: true,
+ pageCount: Math.ceil(items.total / pageSize),
+ },
+ ReactTable.useGlobalFilter,
+ ReactTable.useFilters,
+ ReactTable.useSortBy,
+ ReactTable.usePagination,
+ ReactTable.useRowSelect,
+ ...plugins,
+ );
+
+ const context = React.useMemo>(() => ({
+ status,
+ setStatus,
+ reload,
+ table,
+ }), [JSON.stringify(status), reload, table.state, ...Object.values(tableOptions)]);
+ // Note: the `table` reference is mutated, so cannot use it as dependency for `useMemo` directly
+
+ const TableContext = React.useMemo(() => createTableContext(), []);
+
+ useQuery({
+ table,
+ query,
+ status,
+ setStatus,
+ handleResult: result => { updateItems(result); },
+ }, [reloadTrigger]);
+
+ return (
+
+ {children}
+
+ );
+};
+TableProviderLazy.displayName = 'TableProviderLazy';
+
+
+export type DataTableLazyProps = Omit, 'table' | 'status'>;
+export const DataTableLazy = ({ className, footer, ...propsRest }: DataTableLazyProps) => {
+ const { status, table, reload } = useTable();
+
+ const isEmpty = status.ready && table.rows.length === 0 && !table.canPreviousPage;
+ const isLoading = status.loading;
+
+ // Note: if `status.ready` is `false`, then we're already showing the skeleton loader
+ const showLoadingIndicator = isLoading && status.ready;
+
+ React.useEffect(() => {
+ if (status.ready && table.rows.length === 0 && table.state.pageIndex > 0 && table.canPreviousPage) {
+ // Edge case: no items and yet we are not on the first page. Navigate back to the previous page.
+ table.previousPage();
+ }
+ }, [status.ready, table.rows.length, table.state.pageIndex, table.canPreviousPage]);
+
+ // Use `` by default, unless the table is empty (in which case there are "zero" pages)
+ const footerDefault = isEmpty ? null : ;
+ const footerWithFallback = typeof footer === 'undefined' ? footerDefault : footer;
+
+ return (
+
+
+
+ }
+ />
+ }
+ {...propsRest}
+ >
+ {showLoadingIndicator && }
+
+ );
+};
+DataTableLazy.displayName = 'DataTableLazy';
diff --git a/src/components/tables/DataTable/DataTableStream.module.scss b/src/components/tables/DataTable/DataTableStream.module.scss
new file mode 100644
index 00000000..e9260401
--- /dev/null
+++ b/src/components/tables/DataTable/DataTableStream.module.scss
@@ -0,0 +1,17 @@
+/* Copyright (c) Fortanix, Inc.
+|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
+|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@use '../../../styling/defs.scss' as bk;
+@use './DataTableLazy.module.scss' as dataTableLazy;
+
+
+@layer baklava.components {
+ .bk-data-table-stream {
+ @include bk.component-base(bk-data-table-stream);
+
+ &.bk-data-table-stream--loading {
+ @include dataTableLazy.data-table-loading;
+ }
+ }
+}
diff --git a/src/components/tables/DataTable/DataTableStream.stories.tsx b/src/components/tables/DataTable/DataTableStream.stories.tsx
new file mode 100644
index 00000000..2616136c
--- /dev/null
+++ b/src/components/tables/DataTable/DataTableStream.stories.tsx
@@ -0,0 +1,248 @@
+/* Copyright (c) Fortanix, Inc.
+|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
+|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import * as React from 'react';
+
+import { delay } from '../util/async_util.ts';
+import { sortDateTime } from '../util/sorting_util.ts';
+import { generateData, type User } from '../util/generateData.ts';
+
+import { Button } from '../../actions/Button/Button.tsx';
+import { Panel } from '../../containers/Panel/Panel.tsx';
+import { Banner } from '../../containers/Banner/Banner.tsx';
+import type { DataTableAsyncProps } from './table/DataTable.tsx';
+import * as DataTableStream from './DataTableStream.tsx';
+
+
+export default {
+ component: DataTableStream.DataTableStream,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+ argTypes: {
+ className: {
+ type: { name: 'string', required: false },
+ description: 'CSS class name',
+ control: { type: 'text' },
+ },
+ columns: {
+ type: { name: 'array', required: true },
+ description: 'Table columns',
+ control: { type: 'object' },
+ },
+ endOfStream: {
+ type: { name: 'boolean', required: false },
+ description: 'End of stream flag',
+ control: { type: 'boolean' },
+ },
+ },
+};
+type UserPageState = {
+ offsetTasks: number,
+ offsetApprovalRequests: number,
+};
+type dataTeableLazyTemplateProps = DataTableStream.TableProviderStreamProps &
+{ delay: number, items: Array, endOfStream: boolean, dataTableProps: DataTableAsyncProps };
+const DataTableStreamTemplate = ({dataTableProps, ...props} : dataTeableLazyTemplateProps) => {
+ const columns = React.useMemo(() => props.columns, [props.columns]);
+ const items = React.useMemo(() => props.items, [props.items]);
+ const delayQuery = props.delay ?? null;
+
+ const [itemsProcessed, setItemsProcessed] = React.useState>([]);
+
+ const query: DataTableStream.DataTableQuery = React.useCallback(
+ async ({ previousItem, previousPageState, limit, orderings, globalFilter }) => {
+ if (delayQuery === Number.POSITIVE_INFINITY) return new Promise(() => {}); // Infinite delay
+ if (delayQuery === -1) throw new Error('Failed'); // Simulate failure
+
+ if (delayQuery) await delay(delayQuery);
+
+ let offset = 0;
+
+ if (previousItem) {
+ const previousItemIndex = items.indexOf(previousItem);
+ offset = previousItemIndex === -1 ? 0 : previousItemIndex + 1;
+ }
+
+ const filteredItems = items
+ .filter((row) => {
+ if (!globalFilter || globalFilter.trim() === '') return true;
+
+ const columnsFilterable = columns.filter((column) => !column.disableGlobalFilter);
+ if (!columnsFilterable.length) return false;
+
+ return columnsFilterable.some((column) => {
+ const cell = typeof column.accessor === 'function'
+ ? column.accessor(row, 0, { subRows: [], depth: 0, data: [row] })
+ : undefined;
+ return typeof cell === 'string' && cell.toLowerCase().includes(globalFilter.trim().toLowerCase());
+ });
+ })
+ .sort((a, b) => {
+ if (!orderings[0]) return 0;
+ const { column, direction } = orderings[0];
+ const factor = direction === 'DESC' ? -1 : 1;
+
+ const aValue = a[column as keyof User];
+ const bValue = b[column as keyof User];
+
+ const aNormalized = aValue instanceof Date ? aValue.toISOString() : aValue?.toString();
+ const bNormalized = bValue instanceof Date ? bValue.toISOString() : bValue?.toString();
+
+ return (aNormalized?.localeCompare(bNormalized) || 0) * factor;
+ })
+ .slice(offset, offset + limit);
+
+ return { itemsPage: filteredItems, pageState: null, isEndOfStream: props.endOfStream };
+ },
+ [items, columns, props.endOfStream, delayQuery]
+ );
+
+ return (
+
+
+
+
+
+
+
+ }
+ />
+ }
+ {...dataTableProps}
+ />
+
+
+ );
+};
+
+// Column definitions
+const columnDefinitions = [
+ {
+ id: 'name',
+ accessor: (user: User) => user.name,
+ Header: 'Name',
+ Cell: ({ value }: { value: string }) => value,
+ disableSortBy: false,
+ disableGlobalFilter: false,
+ },
+ {
+ id: 'email',
+ accessor: (user: User) => user.email,
+ Header: 'Email',
+ disableSortBy: false,
+ disableGlobalFilter: false,
+ },
+ {
+ id: 'company',
+ accessor: (user: User) => user.company,
+ Header: 'Company',
+ disableSortBy: false,
+ disableGlobalFilter: false,
+ },
+ {
+ id: 'joinDate',
+ accessor: (user: User) => user.joinDate,
+ Header: 'Joined',
+ Cell: ({ value }: { value: Date }) =>
+ value.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
+ sortType: sortDateTime,
+ disableSortBy: false,
+ disableGlobalFilter: true,
+ },
+];
+
+// Stories
+export const Empty = {
+ args: {
+ columns: columnDefinitions,
+ items: generateData({ numItems: 0 }),
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
+
+export const SinglePage = {
+ args: {
+ columns: columnDefinitions,
+ items: generateData({ numItems: 10 }),
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
+
+export const MultiplePagesSmall = {
+ args: {
+ columns: columnDefinitions,
+ items: generateData({ numItems: 45 }),
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
+
+export const MultiplePagesLarge = {
+ args: {
+ columns: columnDefinitions,
+ items: generateData({ numItems: 1000 }),
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
+
+export const SlowNetwork = {
+ args: {
+ columns: columnDefinitions,
+ items: generateData({ numItems: 1000 }),
+ delay: 1500,
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
+
+export const InfiniteDelay = {
+ args: {
+ columns: columnDefinitions,
+ items: generateData({ numItems: 50 }),
+ delay: Number.POSITIVE_INFINITY,
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
+
+export const StatusFailure = {
+ args: {
+ columns: columnDefinitions,
+ items: generateData({ numItems: 1000 }),
+ delay: -1,
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
+
+export const WithEndOfTablePlaceholder = {
+ args: {
+ columns: columnDefinitions,
+ items: generateData({ numItems: 15 }),
+ dataTableProps: {
+ placeholderEndOfTable:
+ },
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
+
+export const WithExplicitEndOfStream = {
+ args: {
+ columns: columnDefinitions,
+ items: generateData({ numItems: 15 }),
+ endOfStream: false,
+ },
+ render: (args: dataTeableLazyTemplateProps) => ,
+};
diff --git a/src/components/tables/DataTable/DataTableStream.tsx b/src/components/tables/DataTable/DataTableStream.tsx
new file mode 100644
index 00000000..5c23594c
--- /dev/null
+++ b/src/components/tables/DataTable/DataTableStream.tsx
@@ -0,0 +1,540 @@
+/* Copyright (c) Fortanix, Inc.
+|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
+|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import * as React from 'react';
+import { classNames as cx } from '../../../util/componentUtil.ts';
+
+import * as ReactTable from 'react-table';
+import { Spinner } from '../../graphics/Spinner/Spinner.tsx';
+import { PlaceholderEmptyAction } from '../../graphics/PlaceholderEmpty/PlaceholderEmpty.tsx';
+import { Button } from '../../actions/Button/Button.tsx';
+import { type DataTableStatus, type TableContextState, createTableContext, useTable } from './DataTableContext.tsx';
+import { PaginationStream } from './pagination/PaginationStream.tsx';
+import { DataTablePlaceholderError } from './table/DataTablePlaceholder.tsx';
+
+import { DataTableAsync } from './table/DataTable.tsx';
+
+import type { FilterQuery } from '../MultiSearch/filterQuery.ts';
+
+// Table plugins
+import { useCustomFilters } from './plugins/useCustomFilters.tsx';
+
+// Styles
+import cl from './DataTableStream.module.scss';
+
+
+export * from './DataTableContext.tsx';
+export { PaginationStream };
+export { Search, MultiSearch } from './DataTableEager.tsx'; // FIXME: move to a common module
+export {
+ DataTablePlaceholderEmpty,
+ DataTablePlaceholderError,
+ PlaceholderEmptyAction,
+} from './table/DataTablePlaceholder.tsx';
+
+
+export interface ReactTableOptions extends ReactTable.TableOptions {
+ // Add custom properties here
+ //onClick?: (row: ReactTable.Row) => void,
+}
+
+const usePageHistory = () => {
+ type PageIndex = number;
+
+ // Note: conceptually the page history should be a stack. However, in order to prevent timing issues, we maintain
+ // a map keyed with the page index, rather than an array. This allows us to handle situations where our local state
+ // is out of sync with the actual table state.
+ const [pageHistory, setPageHistory] = React.useState