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>(() => new Map()); + + const truncateToPage = React.useCallback( + (pageHistory: Map, pageIndex: PageIndex) => { + const keys = [...pageHistory.keys()]; + if (keys.length === 0 || keys[keys.length - 1] === pageIndex) { + return pageHistory; // Don't update if we don't need to (optimization) + } + return new Map([...pageHistory.entries()].filter(([pageIndexCurrent]) => pageIndexCurrent <= pageIndex)); + }, + [], + ); + + // Present an interface conceptually similar to a stack, but also take explicit page indices for consistency checking + const pageHistoryApi = (pageHistory: Map) => ({ + clear: () => { + const history = new Map(); + + return pageHistoryApi(history); + }, + pop: (pageIndex: PageIndex) => { + const history = truncateToPage(pageHistory, pageIndex); + + return pageHistoryApi(history); + }, + push: (pageIndex: PageIndex, pageHistoryItem: PageHistoryItem) => { + const indices = [...pageHistory.keys()]; + // biome-ignore lint/style/noNonNullAssertion: + const lastIndex: PageIndex = indices[indices.length - 1]!; + // Make sure the page indices are contiguous + if (pageIndex > lastIndex + 1) { + throw new Error('Non-contiguous page indices'); // Should never happen + } + + const history = new Map(pageHistory).set(pageIndex, pageHistoryItem); + + return pageHistoryApi(history); + }, + peak(pageIndex: PageIndex): null | PageHistoryItem { + return pageHistory.get(pageIndex) ?? null; + }, + get: () => pageHistoryApi(pageHistory), + write: () => { + setPageHistory(pageHistory); + }, + }); + + return pageHistoryApi(pageHistory); +}; + +export type DataTableQueryResult = { + itemsPage: ReactTableOptions['data'], + // Custom page state to be stored in page history + pageState?: P, + // This flag is used to manually indicate the end of the stream when all data has been loaded + isEndOfStream?: boolean, +}; + +export type DataTableQuery = + (params: { + previousItem: null | D, + previousPageState?: undefined | P, + offset: number, + pageSize: number, + limit: number, // Note: the `limit` may be different from the `pageSize` (`limit` may include a +1 overflow) + sortBy: Array>, + orderings: Array<{ column: string, direction: 'ASC' | 'DESC' }>, + globalFilter: ReactTable.UseGlobalFiltersState['globalFilter'], + filters: ReactTable.Filters, + customFilters: FilterQuery, + }) => Promise>; + +export type TableProviderStreamProps = { + 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: ReactTableOptions['data'], + // Callback to request the consumer to update controlled state with the given data + updateItems: (items: Array) => void, +}; +export const TableProviderStream = ( + props: TableProviderStreamProps, +) => { + const { + children, + columns, + getRowId, + plugins = [], + initialState, + query, + items, + updateItems, + } = props; + + // Status + const [status, setStatus] = React.useState({ ready: false, loading: false, error: null }); + + // Page History + type PageHistoryItem = { + itemLast: null | D, // Possibly `null` if there are no items yet (or loading) + pageState?: P, + }; + const pageHistory = usePageHistory(); + + // Controlled table state + const initialPageSize = initialState?.pageSize ?? 10; + const [pageSize, setPageSize] = React.useState(initialPageSize); + const [pageCount, setPageCount] = React.useState(1); + const [endOfStream, setEndOfStream] = React.useState(false); + const [partialStream, setPartialStream] = React.useState(false); + + const tableOptions = { + columns, + data: items, + ...(getRowId && { getRowId }), // Add `getRowId` only if it is defined + }; + const table = ReactTable.useTable( + { + ...tableOptions, + + defaultColumn: { + configurable: true, + disableGlobalFilter: true, + disableSortBy: true, + primary: false, + }, + + initialState: { + // useSortBy + sortBy: [{ id: 'name', desc: false }], + + // useGlobalFilter + globalFilter: '', + + // useFilters + filters: [], + + // useCustomFilters + customFilters: [], + + // usePagination + pageSize: initialPageSize, + pageIndex: 0, + + ...initialState, + }, + stateReducer: (state, action, prevState, instance) => { + // Get the previous page state (if any) + let updatedPageHistory = pageHistory.get(); + // New page size + let pageSize = state.pageSize; + + switch (action.type) { + case 'setPageSize': + state.pageIndex = 0; + updatedPageHistory = updatedPageHistory.clear(); + pageSize = action.pageSize; + setPageSize(action.pageSize); + break; + + case 'reload': + case 'setCustomFilters': + case 'setFilter': + case 'toggleSortBy': + case 'setGlobalFilter': + state.pageIndex = 0; + updatedPageHistory = updatedPageHistory.clear(); + break; + + + default: + break; + } + + if ([ + 'tableLoad', + 'reload', + 'gotoPage', + 'setPageSize', + 'setPageIndex', + 'setCustomFilters', + 'setFilter', + 'setGlobalFilter', + 'toggleSortBy', + 'loadMore', + 'reloadCurrentPage', + ].includes(action.type)) { + setStatus(status => ({ ...status, loading: true, error: null })); + + // Initialize previous page history item and partial page history item. + const previousPageHistoryItem = updatedPageHistory.peak(state.pageIndex - 1); + // Get the partial page history of current page + const partialPageHistoryItem = updatedPageHistory.peak(state.pageIndex); + + let queryParams = { + previousItem: previousPageHistoryItem?.itemLast ?? null, + previousPageState: previousPageHistoryItem?.pageState ?? undefined, + offset: state.pageIndex * pageSize, + pageSize, + // Add +1 overflow for end-of-stream checking + // Client can remove 1, if it wants to explicitly set 'isEndOfStream' flag + limit: (pageSize + 1), + sortBy: state.sortBy, + orderings: state.sortBy.map(({ id, desc }) => + ({ column: id, direction: desc ? 'DESC' as const : 'ASC' as const }), + ), + globalFilter: state.globalFilter, + filters: state.filters, + customFilters: state.customFilters, + }; + + if (action.type === 'loadMore') { + queryParams = { + ...queryParams, + previousItem: partialPageHistoryItem?.itemLast ?? null, + previousPageState: partialPageHistoryItem?.pageState ?? undefined, + // Remaining items limit to reach the full 'pageSize' + // Add +1 overflow for end-of-stream checking + // Client can remove 1, if it wants to explicitly set 'isEndOfStream' flag + limit: (pageSize + 1) - (instance?.data ?? []).length, + }; + } + + const queryPromise = query(queryParams); + + queryPromise.then(({ + itemsPage: _itemsPage, + pageState, + isEndOfStream: _isEndOfStream, + }) => { + const itemsPage = _itemsPage.slice(0, pageSize); + + // When load more action is dispatched, combine items from API response with the + // existing items in the table, Otherwise replace the items + const items = action.type === 'loadMore' + ? [...(instance?.data ?? []), ...itemsPage] + : itemsPage; // Slice it down to at most one page length + + // If the API response doesn't explicitly flag the end of the stream, and the + // number of items returned is less than the limit, it indicates a partial stream. + const isPartialStream = typeof _isEndOfStream === 'boolean' + && !_isEndOfStream + && items.length < pageSize; + + let isEndOfStream = true; + + if (typeof _isEndOfStream === 'boolean') { + // If the current page contains partial stream data, we need to wait until more + // data is loaded and the full 'pageSize' is reached. In the meantime, disable the + // next button by setting 'isEndOfStream' to true. Otherwise, set the EOS explicitly + // based on API response. + // i.e, If current page has partial stream then 'isEndOfStream' is set to 'true' + isEndOfStream = isPartialStream ? true : _isEndOfStream; + } else { + // Otherwise if items is not more than a page size, must be EOS + isEndOfStream = _itemsPage.length <= pageSize; + } + + // Set success status + const status = { ready: true, loading: false, error: null }; + setStatus(status); + + + if (status.ready) { + // Set end of stream and partial stream states + if (items.length === 0 && state.pageIndex > 0 && instance?.canPreviousPage) { + // Edge case: no items and yet we are not on the first page. + // This should not happen, unless something changed + // between the end-of-stream check and the subsequent query. + // If it happens, navigate back to the previous page. + // Note: no need to perform a `setPageCount` here, because navigating + // to previous will trigger a new query, + // which will perform a new end-of-stream check. + instance?.previousPage(); + setEndOfStream(true); + setPartialStream(false); + } else if (isEndOfStream) { + // Set page count to be exactly up until the current page (disallow "next") + setPageCount(state.pageIndex + 1); + setEndOfStream(true); + + if (isPartialStream) { + setPartialStream(true); + } else { + setPartialStream(false); + } + } else { + // Add one more page beyond the current page for the user to navigate to + setPageCount(state.pageIndex + 2); + setEndOfStream(false); + setPartialStream(false); + } + } + + // Update table data + updateItems(items); + + // Note: If current page has partial stream then 'isEndOfStream' is set to 'true' + if (!isEndOfStream || isPartialStream) { + // If the current page contains a partial stream or the end of the stream + // hasn't been reached, then: + // 1. If the current page has a partial stream in the page history, then replace it + // to ensure the latest partial page state is stored. + // 2. If end of stream hasn't been reached, then add the current page state to the + // page history + const itemLast = items.slice(-1)[0] || null; + + if (partialPageHistoryItem) { + updatedPageHistory = updatedPageHistory.pop(state.pageIndex); + } + + const pageHistoryItem = pageState !== undefined + ? { itemLast, pageState } + : { itemLast }; + + updatedPageHistory = updatedPageHistory.push(state.pageIndex, pageHistoryItem); + } + + // Update page history state + updatedPageHistory.write(); + }).catch(reason => { + console.error(reason); + const error = reason instanceof Error ? reason : new Error('Unknown error'); + const status = { ready: false, loading: false, error }; + setStatus(status); + }); + } + + return state; + }, + useControlledState: state => { + return ({ + ...state, + pageSize, + pageCount, + endOfStream, + partialStream, + }) + }, + + // 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, + }, + ReactTable.useGlobalFilter, + ReactTable.useFilters, + ReactTable.useSortBy, + ReactTable.usePagination, + ReactTable.useRowSelect, + useCustomFilters, + + ...plugins, + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + React.useEffect(() => { + table.dispatch({ type: 'tableLoad' }); + }, []); + + const reload = () => { + table.dispatch({ type: 'reload' }); + }; + + // 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, + setStatus, + reload, + table, + }), [ + JSON.stringify(status), + table.state, + ...Object.values(tableOptions), + ]); + + const TableContext = React.useMemo(() => createTableContext(), []); + + // If `pageSize` changes, we need to reset to the first page. Otherwise, our `previousItems` cache is no longer valid. + // biome-ignore lint/correctness/useExhaustiveDependencies: + React.useEffect(() => { + if (table.state.pageIndex !== 0) { + table.gotoPage(0); + } + }, [pageSize]); + + return ( + + {children} + + ); +}; +TableProviderStream.displayName = 'TableProviderStream'; + +type DataTableStreamProps = Omit, 'table' | 'status'>; +export const DataTableStream = ({ + className, + footer, + placeholderEndOfTable, + ...propsRest +}: DataTableStreamProps) => { + const { status, table, reload } = useTable(); + + const isLoading = status.loading; + const isEmpty = status.ready && table.rows.length === 0; + + // Note: if `status.ready` is `false`, then we're already showing the skeleton loader + const showLoadingIndicator = isLoading && status.ready; + + const isEndOfStreamReached = !isLoading + && table?.state?.endOfStream; + const isPartialStream = !isLoading + && table?.state?.partialStream; + + const loadMore = () => { + table.dispatch({ type: 'loadMore' }); + }; + + const renderLoadMoreResults = () => { + return ; + }; + + // 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 ( + + + + } + /> + } + placeholderEndOfTable={ + isEndOfStreamReached && !isPartialStream + ? placeholderEndOfTable + : undefined + } + {...propsRest} + > + {showLoadingIndicator && } + + ); +}; +DataTableStream.displayName = 'DataTableStream'; diff --git a/src/components/tables/DataTable/filtering/Filtering.ts b/src/components/tables/DataTable/filtering/Filtering.ts new file mode 100644 index 00000000..4394a1ea --- /dev/null +++ b/src/components/tables/DataTable/filtering/Filtering.ts @@ -0,0 +1,226 @@ +/* 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 { getUnixTime } from 'date-fns'; +import type { + ArrayFieldSpec, + RecordFieldSpec, + FieldQuery, + FilterQuery, + Fields, + Field, + TypeOfFieldSpec, + TypeOfFieldsSpec, +} from '../../MultiSearch/filterQuery.ts'; + +type Primitive = string | number; +type Uuid = string; + +const parseDateTime = (date: Date): number => { + return getUnixTime(date); +}; + +const parseStringField = (field: Primitive) => { + if (typeof field === 'string') { + return field.trim().toLowerCase(); + } + return field; +}; + +const numericOperation = (numericField: number, operation: FieldQuery['operation']): boolean => { + if ('$eq' in operation) { + return numericField === operation.$eq; + } if ('$ne' in operation) { + return numericField !== operation.$ne; + } if ('$gte' in operation) { + return numericField >= operation.$gte; + } if ('$gt' in operation) { + return numericField > operation.$gt; + } if ('$lte' in operation) { + return numericField <= operation.$lte; + } if ('$lt' in operation) { + return numericField < operation.$lt; + } if ('$range' in operation) { + return numericField >= operation.$range[0] && numericField <= operation.$range[1]; + } + throw new TypeError('Unknown query operator'); +}; + +const matchesFieldQuery = ( + fieldSpec: S, + field: TypeOfFieldSpec, + operation: FieldQuery['operation'], +): boolean => { + switch (fieldSpec.type) { + case 'number': { + const fieldAsNumber = field as number; // Unsafe but guaranteed by `TypeOfFieldSpec` + return numericOperation(fieldAsNumber, operation); + } + case 'text': { + const fieldAsString = parseStringField(field as Primitive) as string; // Unsafe but guaranteed by `S` + + if ('$text' in operation) { + return fieldAsString.includes(operation.$text.$search.toLowerCase()); + } + throw new TypeError('Unknown query operator'); + } + case 'datetime': { + const fieldAsDate = parseDateTime(field as Date); // Unsafe but guaranteed by `TypeOfFieldSpec` + return numericOperation(fieldAsDate, operation); + } + case 'array': { + const fieldAsArray = field as Array>; // Unsafe but guaranteed by `S` + + if ('$eq' in operation) { + const arr = operation.$eq as Primitive[]; + return fieldAsArray.every(element => arr.indexOf(element as Primitive) >= 0); + } if ('$ne' in operation) { + const arr = operation.$ne as Primitive[]; + return fieldAsArray.every(element => arr.indexOf(element as Primitive) < 0); + } if ('$all' in operation) { + const elementFieldSpec = fieldSpec.subfield; + if ('$and' in operation.$all && Array.isArray(operation.$all.$and)) { + const operations = operation.$all.$and; + return fieldAsArray.every(element => + operations.every(op => matchesFieldQuery(elementFieldSpec, element, op)) + ); + } if ('$or' in operation.$all && Array.isArray(operation.$all.$or)) { + const operations = operation.$all.$or; + return fieldAsArray.every(element => + operations.some(op => matchesFieldQuery(elementFieldSpec, element, op)) + ); + } + throw new TypeError('Unsupported array operation'); + } if ('$any' in operation) { + const elementFieldSpec = fieldSpec.subfield; + if ('$and' in operation.$any && Array.isArray(operation.$any.$and)) { + const operations = operation.$any.$and; + return fieldAsArray.some(element => + operations.every(op => matchesFieldQuery(elementFieldSpec, element, op)) + ); + } if ('$or' in operation.$any && Array.isArray(operation.$any.$or)) { + const operations = operation.$any.$or; + return fieldAsArray.some(element => + operations.some(op => matchesFieldQuery(elementFieldSpec, element, op)) + ); + } + throw new TypeError('Unsupported array operation'); + } + throw new TypeError('Unknown query operator'); + } + case 'dictionary': { + const fieldAsDictionary = field as string; // Unsafe but guaranteed by `S` + + if ('$all' in operation) { + const val = Object.values(operation.$all)[0]; + return val !== undefined && fieldAsDictionary.includes(String(val)); + } + throw new TypeError('Unknown query operator'); + } + case 'enum': { + const fieldAsEnum = field as string; // Unsafe but guaranteed by `S` + + if ('$in' in operation) { + return operation.$in.indexOf(fieldAsEnum) !== -1; + } if ('$nin' in operation) { + return operation.$nin.indexOf(fieldAsEnum) === -1; + } if ('$eq' in operation) { + return fieldAsEnum.includes(String(operation.$eq)); + } if ('$ne' in operation) { + return !fieldAsEnum.includes(String(operation.$ne)); + } + throw new TypeError('Unknown query operator'); + } + case 'record': { + const fieldAsRecord = field as TypeOfFieldsSpec; // Unsafe but guaranteed by `S` + + if ('$all' in operation) { + return Object.values(fieldAsRecord).every(element => { + // biome-ignore lint/style/noNonNullAssertion: + const elementFieldSpec = Object.values(fieldSpec.fields)[0]!; + if ('$and' in operation.$all && Array.isArray(operation.$all.$and)) { + const operations = operation.$all.$and; + return operations.every(operation => matchesFieldQuery(elementFieldSpec, element, operation)); + } if ('$or' in operation.$all && Array.isArray(operation.$all.$or)) { + const operations = operation.$all.$or; + return operations.some(operation => matchesFieldQuery(elementFieldSpec, element, operation)); + } + const allKey = Object.keys(operation.$all)[0]; + const fieldName = allKey as keyof RecordFieldSpec['fields']; + const operations = Object.values(operation.$all)[0] as FieldQuery['operation']; + if (typeof element === 'object' && element !== null && !Array.isArray(element)) { + const item = element as Record; + // biome-ignore lint/style/noNonNullAssertion: + return matchesFieldQuery(elementFieldSpec, item[fieldName]!, operations); + } + return matchesFieldQuery(elementFieldSpec, element, operations); + }); + } if ('$any' in operation) { + return Object.values(fieldAsRecord).some(element => { + // biome-ignore lint/style/noNonNullAssertion: + const elementFieldSpec = Object.values(fieldSpec.fields)[0]!; + if ('$and' in operation.$any && Array.isArray(operation.$any.$and)) { + const operations = operation.$any.$and; + return operations.every(operation => matchesFieldQuery(elementFieldSpec, element, operation)); + } if ('$or' in operation.$any && Array.isArray(operation.$any.$or)) { + const operations = operation.$any.$or; + return operations.some(operation => matchesFieldQuery(elementFieldSpec, element, operation)); + } + const anyKey = Object.keys(operation.$any)[0]; + const fieldName = anyKey as keyof RecordFieldSpec['fields']; + const operations = Object.values(operation.$any)[0] as FieldQuery['operation']; + if (typeof element === 'object' && element !== null && !Array.isArray(element)) { + const item = element as Record; + // biome-ignore lint/style/noNonNullAssertion: + return matchesFieldQuery(elementFieldSpec, item[fieldName]!, operations); + } + return matchesFieldQuery(elementFieldSpec, element, operations); + }); + } + throw new TypeError('Unknown query operator'); + } + default: throw new TypeError('Unknown field type'); + } +}; + +const getFieldValue = (fieldSpec: Field, item: TypeOfFieldsSpec, fieldName: string) => { + if (fieldSpec.accessor) { + return fieldSpec.accessor(item); + } if (fieldName !== '') { + return item[fieldName as keyof TypeOfFieldsSpec]; + } + throw new TypeError('Unable to get field value, expected either `accessor` or `fieldName` to be configured'); +}; + +// Take some data that corresponds to the given spec (`Fields`), and return that data filtered through the given query +export const filterByQuery = ( + spec: S, + items: Record>, + query: FilterQuery, +): Record> => { + type Item = TypeOfFieldsSpec; + if (query.length > 0) { + const itemsFiltered: Record = Object.entries(items) + .filter(([_itemId, item]) => { + // The `query` contains a list of `FieldQuery`s which should be combined through an `AND` operator + return query.every(fieldQuery => { + const fieldName: null | keyof S = fieldQuery.fieldName; + if (fieldName === null) { return true; } + // biome-ignore lint/style/noNonNullAssertion: + const fieldSpec: Field = spec[fieldName]!; + const fieldValue = getFieldValue(fieldSpec, item, fieldName as string) as TypeOfFieldSpec; + return matchesFieldQuery(fieldSpec, fieldValue, fieldQuery.operation); + }); + }) + .reduce( + (itemsAsRecord, [itemId, item]) => { + itemsAsRecord[itemId] = item; + return itemsAsRecord; + }, + {} as Record, + ); + return itemsFiltered; + } + return items; +}; diff --git a/src/components/tables/DataTable/pagination/Pagination.module.scss b/src/components/tables/DataTable/pagination/Pagination.module.scss new file mode 100644 index 00000000..a4e70999 --- /dev/null +++ b/src/components/tables/DataTable/pagination/Pagination.module.scss @@ -0,0 +1,62 @@ +/* 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; + + +@layer baklava.components { + .bk-pagination { + @include bk.component-base(bk-pagination); + + display: flex; + align-items: center; + justify-content: flex-end; + gap: bk.$spacing-6; + font-weight: bk.$font-weight-regular; + font-size: bk.$font-size-s; + + .pager.pager--indexed { + display: flex; + align-items: center; + gap: bk.$spacing-1; + padding-left: bk.$spacing-6; + border-left: bk.$size-1 solid bk.$theme-pagination-border-default; + + .pager__nav { + display: flex; + + &:not(:global(.nonactive)) { + cursor: pointer; + } + &:global(.nonactive) { + opacity: 0.34; + } + } + } + .pagination__page-input { + text-align: center; + border: bk.$size-1 solid bk.$theme-pagination-border-default; + border-radius: bk.$size-2; + appearance: textfield; + width: bk.$spacing-8; + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; + margin: 0; + } + } + .pagination-main { + display: flex; + gap: bk.$spacing-1; + align-items: center; + } + } + + @media only screen and (width <= 1100px) { + .bk-pagination { + justify-content: flex-start; + flex-wrap: wrap; + } + } +} diff --git a/src/components/tables/DataTable/pagination/Pagination.tsx b/src/components/tables/DataTable/pagination/Pagination.tsx new file mode 100644 index 00000000..434f3330 --- /dev/null +++ b/src/components/tables/DataTable/pagination/Pagination.tsx @@ -0,0 +1,112 @@ +/* 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 { Icon } from '../../../graphics/Icon/Icon.tsx'; +import { Input } from '../../../forms/controls/Input/Input.tsx'; +import { Button } from '../../../actions/Button/Button.tsx'; + +import { + type PageSizeOption, + PaginationSizeSelector, +} from './PaginationSizeSelector.tsx'; +import { useTable } from '../DataTableContext.tsx'; + +import cl from './Pagination.module.scss'; + + +type PaginationProps = { + pageSizeOptions?: Array, +}; +export const Pagination = ({ pageSizeOptions }: PaginationProps) => { + const { table } = useTable(); + const [pageIndexIndicator, setPageIndexIndicator] = React.useState(1); + /* + Available pagination state: + - table.state.pageIndex + - table.state.pageSize + - table.canPreviousPage + - table.canNextPage + - table.pageOptions + - table.pageCount + - table.gotoPage + - table.nextPage + - table.previousPage + - table.setPageSize + */ + + return ( +
+ + +
+ +
+ + + setPageIndexIndicator(Number.parseInt(event.target.value))} + onBlur={() => { + if(pageIndexIndicator > 0 && pageIndexIndicator <= table.pageCount){ + table.gotoPage(pageIndexIndicator - 1); + } else { + table.gotoPage(table.state.pageIndex); + setPageIndexIndicator(table.state.pageIndex + 1); + } + }} + /> + of {Math.max(table.pageCount, 1)} + +
+ +
+
+ ); +}; diff --git a/src/components/tables/DataTable/pagination/PaginationSizeSelector.module.scss b/src/components/tables/DataTable/pagination/PaginationSizeSelector.module.scss new file mode 100644 index 00000000..141cad0e --- /dev/null +++ b/src/components/tables/DataTable/pagination/PaginationSizeSelector.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; + + +@layer baklava.components { + .bk-page-size-selector { + @include bk.component-base(bk-page-size-selector); + + flex: none; + display: flex; + align-items: center; + gap: bk.$spacing-1; + + .page-size-selector__button { + color: bk.$theme-pagination-text-default; + padding: 0; + } + + .page-size-selector__dropdown { + min-width: bk.$spacing-10; + + li button { + justify-content: center; + } + } + } +} diff --git a/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx b/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx new file mode 100644 index 00000000..45c44639 --- /dev/null +++ b/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx @@ -0,0 +1,59 @@ +/* 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 { classNames as cx } from '../../../../util/componentUtil.ts'; + +import { Icon } from '../../../graphics/Icon/Icon.tsx'; +import { Button } from '../../../actions/Button/Button.tsx'; +import { DropdownMenuProvider } from '../../../overlays/DropdownMenu/DropdownMenuProvider.tsx'; + +import { useTable } from '../DataTableContext.tsx'; + +import cl from './PaginationSizeSelector.module.scss'; + + +export type PageSizeOption = number; +export const defaultPageSizeOptions: Array = [10, 25, 50, 100]; + +type PaginationSizeSelectorProps = { + pageSizeOptions?: undefined | Array, + pageSizeLabel?: undefined | string, +}; +export const PaginationSizeSelector = (props: PaginationSizeSelectorProps) => { + const { pageSizeOptions = defaultPageSizeOptions, pageSizeLabel = 'Rows per page' } = props; + + const { table } = useTable(); + + return ( +
+ {pageSizeLabel}: + + ( + { + table.setPageSize(pageSize); + context.close(); + }} + /> + ))} + > + {({ props }) => ( + + )} + +
+ ); +}; diff --git a/src/components/tables/DataTable/pagination/PaginationStream.module.scss b/src/components/tables/DataTable/pagination/PaginationStream.module.scss new file mode 100644 index 00000000..d5317faa --- /dev/null +++ b/src/components/tables/DataTable/pagination/PaginationStream.module.scss @@ -0,0 +1,47 @@ +/* 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 './Pagination.module.scss'; + + +@layer baklava.components { + .bk-pagination--stream { + @include bk.component-base(bk-pagination-stream); + + .pagination__load-more-action { + display: flex; + align-items: center; + margin-right: auto; + } + + .pagination__pager { + display: flex; + align-items: center; + gap: bk.$spacing-5; + padding-left: bk.$spacing-6; + border-left: bk.$size-1 solid bk.$theme-pagination-border-default; + + .pager__nav { + color: bk.$theme-pagination-text-default; + padding-left: 0; + padding-right: 0; + + display: flex; + + &:global(.nonactive) { + opacity: 0.4; + } + + &.pager__nav--first { --keep: ; } + &.pager__nav--prev, + &.pager__nav--next { + display: flex; + align-items: center; + } + } + } + } +} diff --git a/src/components/tables/DataTable/pagination/PaginationStream.tsx b/src/components/tables/DataTable/pagination/PaginationStream.tsx new file mode 100644 index 00000000..61d531b3 --- /dev/null +++ b/src/components/tables/DataTable/pagination/PaginationStream.tsx @@ -0,0 +1,71 @@ +/* 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 { classNames as cx } from '../../../../util/componentUtil.ts'; +import type * as React from 'react'; + +import { Icon } from '../../../graphics/Icon/Icon.tsx'; +import { Button } from '../../../actions/Button/Button.tsx'; + +import { type PageSizeOption, PaginationSizeSelector } from './PaginationSizeSelector.tsx'; +import { useTable } from '../DataTableContext.tsx'; + +import cl from './PaginationStream.module.scss'; + + +type PaginationStreamPagerProps = { + pageSizeOptions?: PageSizeOption, +}; +export const PaginationStreamPager = ({ pageSizeOptions }: PaginationStreamPagerProps) => { + const { table } = useTable(); + + return ( +
+ + + + + +
+ ); +}; + +type PaginationStreamProps = { + pageSizeOptions?: Array, + pageSizeLabel?: string, + renderLoadMoreResults?: () => React.ReactNode, +}; +export const PaginationStream = ({ renderLoadMoreResults, pageSizeOptions, pageSizeLabel }: PaginationStreamProps) => { + return ( +
+ {renderLoadMoreResults && ( +
{renderLoadMoreResults?.()}
+ )} + + +
+ ); +}; diff --git a/src/components/tables/DataTable/plugins/useCustomFilters.tsx b/src/components/tables/DataTable/plugins/useCustomFilters.tsx new file mode 100644 index 00000000..0e5266ef --- /dev/null +++ b/src/components/tables/DataTable/plugins/useCustomFilters.tsx @@ -0,0 +1,48 @@ +/* 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 * as ReactTable from 'react-table'; +import type { FilterQuery } from '../../MultiSearch/filterQuery.ts'; + + +// Actions +ReactTable.actions.setCustomFilters = 'setCustomFilters'; + +const reducer = ( + state: ReactTable.TableState, + action: ReactTable.ActionType, +): ReactTable.ReducerTableState | undefined => { + if (action.type === ReactTable.actions.setCustomFilters) { + return { + ...state, + customFilters: typeof action.customFilters === 'function' + ? action.customFilters(state.customFilters) + : action.customFilters, + }; + } + + return state; +}; + +const useInstance = (instance: ReactTable.TableInstance) => { + const { dispatch, } = instance; + //const customFilters = instance.state.customFilters; + + const setCustomFilters = React.useCallback( + (customFilters: FilterQuery) => { + return dispatch({ type: ReactTable.actions.setCustomFilters, customFilters }); + }, + [dispatch], + ); + + Object.assign(instance, { + setCustomFilters, + }); +}; + +export const useCustomFilters = (hooks: ReactTable.Hooks): void => { + hooks.stateReducers.push(reducer); + hooks.useInstance.push(useInstance); +}; diff --git a/src/components/tables/DataTable/plugins/useRowSelectColumn.module.scss b/src/components/tables/DataTable/plugins/useRowSelectColumn.module.scss new file mode 100644 index 00000000..5b9d529f --- /dev/null +++ b/src/components/tables/DataTable/plugins/useRowSelectColumn.module.scss @@ -0,0 +1,14 @@ +/* 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; + +@layer baklava.components { + .bk-data-table-row-select { + @include bk.component-base(bk-data-table-row-select); + + width: bk.$spacing-11; + max-width: bk.$spacing-11; + } +} diff --git a/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx b/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx new file mode 100644 index 00000000..ce3cfe43 --- /dev/null +++ b/src/components/tables/DataTable/plugins/useRowSelectColumn.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 type * as ReactTable from 'react-table'; + +import { Checkbox } from '../../../forms/controls/Checkbox/Checkbox.tsx'; + +import cl from './useRowSelectColumn.module.scss'; + + +// `react-table` plugin for row selection column. Note: depends on `react-table`'s `useRowSelect` plugin. +export const useRowSelectColumn = (hooks: ReactTable.Hooks): void => { + // Prepend a column with row selection checkboxes + hooks.visibleColumns.push(columns => [ + { + id: 'selection', + className: cl['bk-data-table-row-select'], + Header: ({ getToggleAllPageRowsSelectedProps }) => { + const { checked, onChange } = getToggleAllPageRowsSelectedProps(); + return ( + + ); + }, + Cell: ({ row }: ReactTable.CellProps) => { + const { checked, onChange } = row.getToggleRowSelectedProps(); + return ( + + ); + }, + }, + ...columns, + ]); +}; diff --git a/src/components/tables/DataTable/table/DataTable.module.scss b/src/components/tables/DataTable/table/DataTable.module.scss new file mode 100644 index 00000000..8b772fda --- /dev/null +++ b/src/components/tables/DataTable/table/DataTable.module.scss @@ -0,0 +1,152 @@ +/* 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; + + +@layer baklava.components { + .bk-data-table { + @include bk.component-base(bk-data-table); + width: 100%; + + table.bk-data-table__table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + + th { + padding: bk.$spacing-5 bk.$spacing-2; + vertical-align: middle; + white-space: nowrap; // Header should never wrap to multiple lines + text-overflow: ellipsis; + overflow: hidden; // Hide overflow by default; may need to override this for things like local dropdown menus + } + + td { + padding: bk.$spacing-5 bk.$spacing-2; + vertical-align: middle; + white-space: nowrap; // Prevent wrapping by default, can override this on case-by-case basis + overflow: hidden; // Hide overflow by default; may need to override this for things like local dropdown menus + + // *If* `white-space: wrap` is enabled, then allow wrapping mid-word if necessary, to prevent overflow caused + // by a long word without spaces/hyphens/etc. + overflow-wrap: anywhere; + } + + thead { + border-bottom: 1px solid bk.$theme-rule-default; + + th { + cursor: default; + padding-bottom: 1rem; + + color: bk.$theme-table-text-body-secondry; + font-weight: bk.$font-weight-semibold; + font-size: bk.$font-size-s; + text-transform: uppercase; + + .column-header { + display: flex; + align-items: center; + gap: bk.$spacing-1; + + .sort-indicator { + width: bk.$spacing-4; + + &.sort-indicator--inactive { + opacity: 0; + } + + transition: transform 240ms ease-in-out, + opacity 120ms ease-in-out; + + &.asc { + transform: rotateX(180deg); // Rotate along the X-axis (i.e. it flips up-down) + } + } + + &:hover { + .sort-indicator--inactive { + opacity: 1; + } + } + } + } + } + + tbody { + tr:not(.bk-data-table__placeholder) { + &:not(:last-of-type) { + border-bottom: 1px solid bk.$theme-rule-default; + } + + &:hover, + &.selected { + background-color: bk.$theme-table-background-hover; + + // Item cell + > td { + font-size: bk.$font-size-m; + } + } + } + } + + tfoot { + td { + overflow: visible; // Allow overflow due to dropdown menu + } + } + } + } + + @media only screen and (width <=1100px) { + table.bk-data-table { + display: flex; + flex-direction: column; + + // Common styling for both `thead tr` and `tbody tr` + tr { + padding: 0.6rem 0; + + display: flex; + flex-direction: column; + white-space: normal; + + > td, + > th { + padding: 0.6rem 0; + + display: flex; + flex-direction: row; + } + } + + thead { + border-bottom: none; + + display: flex; + flex-direction: column; + } + + tbody { + display: flex; + flex-direction: column; + + tr:not(.bk-data-table__placeholder) { + margin: 1.5rem 0; + padding: 1.5rem; + + &:not(:last-of-type) { + border-bottom: none; + } + + > td:empty { + display: none; + } + } + } + } + } +} diff --git a/src/components/tables/DataTable/table/DataTable.tsx b/src/components/tables/DataTable/table/DataTable.tsx new file mode 100644 index 00000000..03582d1a --- /dev/null +++ b/src/components/tables/DataTable/table/DataTable.tsx @@ -0,0 +1,249 @@ +/* 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 type * as React from 'react'; +import { classNames as cx, type ClassNameArgument, type ComponentProps } from '../../../../util/componentUtil.ts'; +import type * as ReactTable from 'react-table'; + +import { useScroller } from '../../../../layouts/util/Scroller.tsx'; +import { Icon } from '../../../graphics/Icon/Icon.tsx'; + +import { + DataTablePlaceholderSkeleton, + DataTablePlaceholderEmpty, + DataTablePlaceholderError, +} from './DataTablePlaceholder.tsx'; +import type { DataTableStatus } from '../DataTableContext.tsx'; + +import cl from './DataTable.module.scss'; + + +// Note: `placeholder` is included in `table` props as part of "Standard HTML Attributes", but it's not actually a +// valid `` attribute, so we can safely override it. +type DataTableProps = Omit, 'placeholder'> & { + table: ReactTable.TableInstance, + columnGroups?: React.ReactNode, + footer?: React.ReactNode, + placeholder?: React.ReactNode, + endOfTablePlaceholder?: React.ReactNode, + children?: React.ReactNode, +}; +export const DataTable = (props: DataTableProps) => { + const { + table, + columnGroups, + footer, + placeholder, + endOfTablePlaceholder, + children, + ...propsRest + } = props; + + // Currently we only support one header group + const headerGroup: undefined | ReactTable.HeaderGroup = table.headerGroups[0]; + if (!headerGroup) { return null; } + + return ( +
+ {columnGroups} + + + + {/* + ); + })} + + + + {typeof placeholder !== 'undefined' && + + + + } + {typeof placeholder === 'undefined' && table.page.map(row => { + table.prepareRow(row); + const { key: rowKey, ...rowProps } = row.getRowProps(); + return ( + + {/**/} + {row.cells.map(cell => { + const { key: cellKey, ...cellProps } = cell.getCellProps(); + return ( + + ); + })} + + ); + })} + {typeof endOfTablePlaceholder !== 'undefined' && + + + + } + + + {footer && + + + + + + } +
{/ * Empty header for the selection checkbox column */} + + {headerGroup.headers.map((column: ReactTable.HeaderGroup) => { + const { key: headerKey, ...headerProps } = column.getHeaderProps([ + // Note: the following are supported through custom interface merging in `src/types/react-table.d.ts` + { + className: column.className, + style: column.style, + }, + column.getSortByToggleProps(), + ]); + + return ( + +
{/* Wrapper element needed to serve as flex container */} + + {column.render('Header')} + + {column.canSort && + + } +
+
+ {placeholder} +
+ { row.toggleRowSelected(); }} + /> + + {cell.render('Cell')} +
+ {endOfTablePlaceholder} +
+ {footer} +
+ ); +}; + + +type DataTableSyncProps = DataTableProps & { + classNameTable?: ClassNameArgument, + placeholderEmpty?: React.ReactNode, + placeholderSkeleton?: React.ReactNode, + status: DataTableStatus, +}; +export const DataTableSync = (props: DataTableSyncProps) => { + const { + className, + classNameTable, + placeholderEmpty = , + placeholderSkeleton = , + status, + ...propsRest + } = props; + + const isEmpty = status.ready && props.table.page.length === 0; + const scrollProps = useScroller({ scrollDirection: 'horizontal' }); + const renderPlaceholder = (): React.ReactNode => { + if (!status.ready) { + return placeholderSkeleton; + } + if (isEmpty) { + return placeholderEmpty; + } + return undefined; + }; + + // Note: the wrapper div isn't really necessary, but we include it for structural consistency with `DataTableAsync` + return ( +
+ +
+ ); +}; +DataTableSync.displayName = 'DataTableSync'; + + +export type DataTableAsyncProps = DataTableProps & { + classNameTable?: ClassNameArgument, + status: DataTableStatus, + placeholderSkeleton?: React.ReactNode, + placeholderEmpty?: React.ReactNode, + placeholderError?: React.ReactNode, + placeholderEndOfTable?: React.ReactNode, + children?: React.ReactNode, +}; +export const DataTableAsync = (props: DataTableAsyncProps) => { + const { + className, + classNameTable, + status, + placeholderSkeleton = , + placeholderEmpty = , + placeholderError = , + placeholderEndOfTable, + children, + ...propsRest + } = props; + const table = props.table; + + const isFailed = status.error !== null; + const isLoading = status.loading; + const isEmpty = status.ready && table.page.length === 0; + const scrollProps = useScroller({ scrollDirection: 'horizontal' }); + + const renderPlaceholder = (): React.ReactNode => { + if (isFailed) { + return placeholderError; + } + if (isLoading) { + // If there is still valid cached data, show it, otherwise show a skeleton placeholder + return status.ready ? undefined : placeholderSkeleton; + } + if (isEmpty) { + return placeholderEmpty; + } + return undefined; + }; + + return ( +
+ {children} + + +
+ ); +}; diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.module.scss b/src/components/tables/DataTable/table/DataTablePlaceholder.module.scss new file mode 100644 index 00000000..72f567c9 --- /dev/null +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.module.scss @@ -0,0 +1,46 @@ +/* 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; + +@layer baklava.components { + .bk-table-placeholder { + &:not(.bk-table-placeholder--skeleton) { + padding: bk.$spacing-14 bk.$spacing-2; + } + + &.bk-table-placeholder--skeleton { + align-items: stretch; + justify-content: space-around; + gap: 1rem; + margin: calc(-1 * bk.$spacing-5) calc(-1 * bk.$spacing-2); + + > .skeleton-row { + border-bottom: 1px solid bk.$theme-rule-default; + margin: bk.$spacing-1 0; + height: bk.$spacing-10; + display: flex; + align-items: center; + + .skeleton-cell { + flex: 1; + padding-left: bk.$spacing-2; + &::after { + content: ' '; + $shimmer-base-color: light-dark(#F8F8F8, #2D2D50); // FIXME: design token + $shimmer-highlight-color: light-dark(#CBCEDB, #767699); // FIXME: design token + @include bk.shimmer($base-color: $shimmer-base-color, $highlight-color: $shimmer-highlight-color); + height: bk.$spacing-2; + border-radius: bk.$border-radius-xl; + display: block; + width: 60%; + } + } + } + } + + &.bk-table-placeholder--empty { --keep: ; } + &.bk-table-placeholder--error { --keep: ; } + } +} diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.tsx b/src/components/tables/DataTable/table/DataTablePlaceholder.tsx new file mode 100644 index 00000000..8854e7ca --- /dev/null +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.tsx @@ -0,0 +1,63 @@ +/* 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 { PlaceholderEmpty, type PlaceholderEmptyProps } from '../../../graphics/PlaceholderEmpty/PlaceholderEmpty.tsx'; +import { useTable } from '../DataTableContext.tsx'; + +export { + PlaceholderEmptyAction, +} from '../../../graphics/PlaceholderEmpty/PlaceholderEmpty.tsx'; + +import cl from './DataTablePlaceholder.module.scss'; + + +// Loading skeleton (when there's no data to show yet) +type DataTablePlaceholderSkeletonProps = { className?: ClassNameArgument }; +export const DataTablePlaceholderSkeleton = (props: DataTablePlaceholderSkeletonProps) => { + const { table } = useTable(); + return ( +
+ {Array.from({ length: 6 }).map((_, index) => + // biome-ignore lint/suspicious/noArrayIndexKey: no other unique identifier available + + {table.visibleColumns.map((col, index) => + // biome-ignore lint/suspicious/noArrayIndexKey: no other unique identifier available + + )} + , + )} +
+ ); +}; + +// Empty table (ready but no data) +type DataTablePlaceholderEmptyProps = Omit & { + // Make `placeholderMessage` optional + title?: PlaceholderEmptyProps['title'], +}; +export const DataTablePlaceholderEmpty = (props: DataTablePlaceholderEmptyProps) => { + return ( + + ); +}; + +type DataTablePlaceholderErrorProps = Omit & { + // Make `placeholderMessage` optional + title?: React.ComponentProps['title'], +}; +export const DataTablePlaceholderError = (props: DataTablePlaceholderErrorProps) => { + return ( + + ); +}; diff --git a/src/components/tables/MultiSearch/MultiSearch.scss b/src/components/tables/MultiSearch/MultiSearch.scss new file mode 100644 index 00000000..081a85fd --- /dev/null +++ b/src/components/tables/MultiSearch/MultiSearch.scss @@ -0,0 +1,155 @@ +/* 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 '../../../style/variables.scss' as *; +@use '../../../components/overlays/dropdown/Dropdown.scss'; + +@layer baklava.components { + .bk-search-input { + @include bk.component-base(bk-search-input); + position: relative; + display: flex; + flex: 1; + padding: $sizing-none; + + background-color: $light-color-2; + border: 0.2rem solid $neutral-color-1; + border-radius: $sizing-2; + + &.bk-search-input--active { + background-color: $light-color-1; + border-color: $accent-color-light-2; + } + + &:hover:not(.bk-search-input--active) { + background-color: $light-color-1; + border-color: rgba($accent-color-light-2, 0.33); + outline: none; + } + + .bk-input { + .bk-input__input { + background-color: transparent; + border: none; + } + } + + .bk-search-input__search-icon, + .bk-search-input__search-key { + padding: 1rem; + padding-right: $sizing-none; + } + + .bk-search-input__search-icon { + width: $sizing-m; + opacity: 0.5; + color: #8AA1B0; + } + + .bk-search-input__search-key { + font-size: $font-size-s; + font-weight: $font-weight-light; + line-height: $line-height-2; + flex: 1 0 auto; + } + + .bk-search-input__input { + width: 100%; + } + } + + .bk-multi-search__filters { + display: flex; + margin-top: $sizing-s; + + .bk-multi-search__filters-wrapper { + display: flex; + flex-wrap: wrap; + gap: $sizing-s; + + .bk-multi-search__filter { + .filter-operand { + margin-left: $sizing-xxs; + } + + .filter-value { + margin-left: $sizing-xxs; + font-weight: $font-weight-semibold; + } + } + } + + .bk-multi-search__filter-actions { + margin-left: auto; + flex-shrink: 0; + padding-left: $sizing-s; + + .clear-all { + color: $accent-color; + + &:hover { + cursor: pointer; + text-decoration: underline; + } + } + } + } + + .bk-multi-search__operators { + .operator { + display: flex; + justify-content: center; + } + } + + .bk-multi-search__alternatives { + .bk-multi-search__alternatives-group { + .bk-checkbox { + padding: $sizing-s; + } + } + + .bk-multi-search__alternatives-action { + padding-top: $sizing-s; + display: flex; + justify-content: center; + } + } + + .bk-multi-search__date-time { + .bk-multi-search__date-time-group { + .bk-multi-search__date-time-label { + margin-bottom: $sizing-xs; + font-weight: $font-weight-semibold; + } + + padding: $sizing-s; + } + + .bk-multi-search__date-time-action { + padding-top: $sizing-s; + display: flex; + justify-content: center; + } + } + + + .bk-multi-search__suggested-keys { + .bk-multi-search__suggested-key-input .bk-input__input { + width: auto; + } + } + + .bk-multi-search__error-msg, + .bk-multi-search__dropdown-error-msg { + padding-top: $sizing-s; + color: $status-color-error; + max-width: $sizing-6 * 10; + display: block; + } + + .bk-multi-search__dropdown-error-msg { + padding: $sizing-s; + } +} diff --git a/src/components/tables/MultiSearch/MultiSearch.stories.tsx b/src/components/tables/MultiSearch/MultiSearch.stories.tsx new file mode 100644 index 00000000..1fc540ab --- /dev/null +++ b/src/components/tables/MultiSearch/MultiSearch.stories.tsx @@ -0,0 +1,236 @@ +/* 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 { getDay as dateGetDay, startOfDay as dateStartOfDay, endOfDay as dateEndOfDay, sub as dateSub } from 'date-fns'; + +import * as React from 'react'; + +import type * as FQ from './filterQuery.ts'; +import * as MultiSearch from './MultiSearch.tsx'; + + +export default { + component: MultiSearch, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, +}; +export const Standard = () => { + const severityFieldSpec: FQ.EnumFieldSpec = { + type: 'enum', + operators: ['$eq', '$ne', '$in', '$nin'], + label: 'Severity', + alternatives: { + INFO: { label: 'Info' }, + WARNING: { label: 'Warning' }, + ERROR: { label: 'Error' }, + CRITICAL: { label: 'Critical' }, + }, + }; + + const keyOpsFieldSpec: FQ.ArrayFieldSpec = { + type: 'array', + operators: ['$eq', '$ne', '$any', '$all'], + label: 'Key Ops', + subfield: { + type: 'enum', + operators: ['$in', '$nin'], + label: 'Key Ops', + alternatives: { + ENCRYPT: { label: 'Encrypt' }, + DECRYPT: { label: 'Decrypt' }, + WRAP: { label: 'Wrap' }, + UNWRAP: { label: 'Unwrap' }, + }, + operatorInfo: { + '$in': { label: 'one of' }, + '$nin': { label: 'none of' }, + }, + }, + }; + + const initiatorFieldSpec: FQ.TextFieldSpec = { + type: 'text', + operators: ['$text'], + label: 'Initiator', + placeholder: 'Search initiator', + }; + + const countFieldSpec: FQ.Field = { + type: 'number', + operators: ['$eq', '$lt', '$lte', '$gt', '$gte', '$ne'], + label: 'Count', + placeholder: 'Search ip-address', + }; + + const customAttributesFieldSpec: FQ.DictionaryFieldSpec = { + type: 'dictionary', + operators: ['$all'], + label: 'Custom Attributes', + suggestedKeys: { + vehicle: { label: 'Vehicle' }, + book: { label: 'Book' }, + }, + }; + + const createdAtFieldSpec: FQ.DateTimeFieldSpec = { + type: 'datetime', + operators: ['$gt', '$range'], + label: 'Created', + placeholder: 'Search', + minDate: dateSub(dateStartOfDay(new Date()), { days: 10 }), + maxDate: dateEndOfDay(new Date()), + }; + + const fields = { + severity: severityFieldSpec, + keyOps: keyOpsFieldSpec, + initiator: initiatorFieldSpec, + count: countFieldSpec, + custom: customAttributesFieldSpec, + createdAt: createdAtFieldSpec, + }; + + const defaultFilters = [{ + fieldName: 'initiator', + operation: { + $text: { + $search: 'info', + }, + }, + }]; + + const [filters, setFilters] = React.useState(defaultFilters); + + const query = (filter: FQ.FilterQuery) => setFilters(filter); + + return ( + + ); +}; + +export const WithValidation = () => { + const uuidFieldSpec: FQ.TextFieldSpec = { + type: 'text', + operators: ['$text'], + label: 'UUID', + placeholder: '18DA82C7-E445-48CB-90F3-8A159741C85E', + validator: ({ buffer }) => { + const isValid = /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(buffer); + return { + isValid, + message: isValid ? '' : 'Please enter a valid UUID', + }; + }, + }; + + const severityFieldSpec: FQ.EnumFieldSpec = { + type: 'enum', + operators: ['$eq', '$ne', '$in', '$nin'], + label: 'Severity', + alternatives: { + INFO: { label: 'Info' }, + WARNING: { label: 'Warning' }, + ERROR: { label: 'Error' }, + CRITICAL: { label: 'Critical' }, + }, + validator: ({ buffer }) => { + const isValid = buffer.toLowerCase() !== ''; + return { + isValid, + message: isValid ? '' : 'Please enter a valid severity', + }; + }, + }; + + const keyOpsFieldSpec: FQ.ArrayFieldSpec = { + type: 'array', + operators: ['$eq', '$ne', '$any', '$all'], + label: 'Key Ops', + subfield: { + type: 'enum', + operators: ['$in', '$nin'], + label: 'Key Ops', + alternatives: { + ENCRYPT: { label: 'Encrypt' }, + DECRYPT: { label: 'Decrypt' }, + WRAP: { label: 'Wrap' }, + UNWRAP: { label: 'Unwrap' }, + }, + operatorInfo: { + '$in': { label: 'one of' }, + '$nin': { label: 'none of' }, + }, + }, + validator: ({ buffer }) => { + const isValid = buffer.includes('ENCRYPT'); + return { + isValid, + message: isValid ? '' : 'Keys ops must include "Encrypt"', + }; + }, + }; + + const countFieldSpec: FQ.Field = { + type: 'number', + operators: ['$eq', '$lt', '$lte', '$gt', '$gte', '$ne'], + label: 'Count', + placeholder: 'Search ip-address', + validator: ({ buffer }) => { + const isValid = /^[0-9]*$/.test(buffer); + return { + isValid, + message: isValid ? '' : 'Please enter a valid number', + }; + }, + }; + + const customAttributesFieldSpec: FQ.DictionaryFieldSpec = { + type: 'dictionary', + operators: ['$all'], + label: 'Custom attributes', + suggestedKeys: { + vehicle: { label: 'Vehicle' }, + book: { label: 'Book' }, + }, + validator: ({ key, buffer }) => { + const isValid = key.toLowerCase() === 'book' && buffer.toLowerCase() === 'data structure'; + return { + isValid, + message: isValid ? '' : 'Please enter a valid attribute', + }; + }, + }; + + const createdAtFieldSpec: FQ.DateTimeFieldSpec = { + type: 'datetime', + operators: ['$gt', '$range'], + label: 'Created', + placeholder: 'Search', + minDate: dateSub(dateStartOfDay(new Date()), { days: 10 }), + maxDate: dateEndOfDay(new Date()), + validator: ({ dateTime, startDateTime, endDateTime }) => { + const isValid = dateGetDay(endDateTime) !== 0; // Day must not be Sunday (random validation rule for testing) + return { isValid, message: !isValid ? 'Please pick an end day other than Sunday' : '' }; + }, + }; + + const fields = { + uuid: uuidFieldSpec, + severity: severityFieldSpec, + keyOps: keyOpsFieldSpec, + count: countFieldSpec, + custom: customAttributesFieldSpec, + createdAt: createdAtFieldSpec, + }; + + const [filters, setFilters] = React.useState([]); + + const query = (filter: FQ.FilterQuery) => setFilters(filter); + + return ( + + ); +}; diff --git a/src/components/tables/MultiSearch/MultiSearch.tsx b/src/components/tables/MultiSearch/MultiSearch.tsx new file mode 100644 index 00000000..5f84b0c4 --- /dev/null +++ b/src/components/tables/MultiSearch/MultiSearch.tsx @@ -0,0 +1,1683 @@ +/* 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 Random from '../../../util/random.ts'; +import * as ObjectUtil from '../../../util/objectUtil.ts'; +import { + isEqual, + fromUnixTime, + format as dateFormat, + getUnixTime, + set as setDate, + isAfter as isDateAfter, + isBefore as isDateBefore, + isEqual as isDateEqual, +} from 'date-fns'; + +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { classNames as cx, type ClassNameArgument, type ComponentProps } from '../../../util/componentUtil.ts'; +// import * as Popper from 'react-popper'; +import { mergeRefs } from '../../../util/reactUtil.ts'; +import { useOutsideClickHandler } from '../../../util/hooks/useOutsideClickHandler.ts'; +import { useFocus } from '../../../util/hooks/useFocus.ts'; + +import { Icon } from '../../graphics/Icon/Icon.tsx'; +import { Tag } from '../../text/Tag/Tag.tsx'; +import { Button } from '../../actions/Button/Button.tsx'; +import { Input } from '../../forms/controls/Input/Input.tsx'; +import { CheckboxGroup } from '../../forms/fields/CheckboxGroup/CheckboxGroup.tsx'; +// import * as Dropdown from '../../overlays/dropdown/Dropdown.tsx'; +import { DropdownMenu, DropdownMenuContext } from '../../overlays/DropdownMenu/DropdownMenu.tsx'; +// import { DateTimePicker } from '../../forms/datetime/DateTimePicker.tsx'; + +import * as FQ from './filterQuery.ts'; + +//import './MultiSearch.scss'; + + +// Utilities +type Primitive = null | string | number | bigint | boolean; + + +// Map operators to a human-readable string to be shown in the UI + +const numberOperatorsToSymbolMap: Record = { + '$eq': '\u003D', + '$lt': '\u003C', + '$lte': '\u2264', + '$gt': '\u003E', + '$gte': '\u2265', + '$ne': '\u2260', +} as const; + +const dateTimeFieldOperatorsToSymbolMap: Record = { + '$eq': '\u003D', + '$lt': '\u003C', + '$lte': '\u2264', + '$gt': '\u003E', + '$gte': '\u2265', + '$ne': '\u2260', + '$range': 'Range', +} as const; + +const enumOperatorsToSymbolMap: Record = { + '$eq': 'is', + '$ne': 'is not', + '$in': 'is one of', + '$nin': 'is none of', +}; + +const arrayOperatorsToSymbolMap: Record = { + '$eq': 'is', + '$ne': 'is not', + '$any': 'contains any matching', + '$all': 'contains all matching', +}; + +const getOperatorLabel = (operator: FQ.Operator, field: FQ.Field): string => { + let label = ''; + + if (field.operatorInfo && operator in field.operatorInfo) { + label = field.operatorInfo[operator]?.label ?? ''; + } else if (field.type === 'array') { + label = arrayOperatorsToSymbolMap[operator as FQ.ArrayFieldOperator] ?? ''; + } else if (field.type === 'enum') { + label = enumOperatorsToSymbolMap[operator as FQ.EnumFieldOperator] ?? ''; + } else if (field.type === 'number') { + label = numberOperatorsToSymbolMap[operator as FQ.NumberFieldOperator] ?? ''; + } else if (field.type === 'datetime') { + label = dateTimeFieldOperatorsToSymbolMap[operator as FQ.DateTimeFieldOperator] ?? ''; + } + + return label; +}; + + +// +// Query filter management +// + +type UseFiltersProps = { + fields: FQ.Fields, // Field definitions + customFilters: FQ.FilterQuery, // The filter query + query: (filters: FQ.FilterQuery) => void, // Callback to be called with the latest filter query +}; +// Custom hook to manage a `FilterQuery` instance +const useFilters = (props: UseFiltersProps) => { + const { + fields, + customFilters, + query, + } = props; + + const [filters, setFilters] = React.useState([]); + + React.useEffect(() => { + setFilters(customFilters ?? []); + }, [customFilters]); + + const addFilter = (options: { + fieldName: FQ.FieldName, + value: Primitive | Array, + selectedOperator?: undefined | null | FQ.Operator, + selectedSubOperator?: undefined | null | FQ.Operator, + key?: undefined | string, + }) => { + const { + fieldName, + value, + selectedOperator = null, + selectedSubOperator = null, + key = '', + } = options; + + const fieldQuery = FQ.encodeFieldQuery(fieldName, value, selectedOperator, selectedSubOperator, fields, key); + + if (fieldName && fields && typeof fields[fieldName]?.onAddFilter === 'function' && fieldQuery) { + const field = fields[fieldName]; + // biome-ignore lint/style/noNonNullAssertion: + const updatedFilters = field.onAddFilter!(fieldQuery, filters); + query?.(updatedFilters); + } else if (fieldQuery) { + const newFilters = [...filters, fieldQuery]; + query?.(newFilters); + } + }; + + const removeFilter = (index: number) => { + const newFilters = filters.filter((_, i) => i !== index); + query?.(newFilters); + }; + + const removeAllFilters = () => { + query?.([]); + }; + + return { + filters, + setFilters, + addFilter, + removeFilter, + removeAllFilters, + }; +}; + +type FiltersProps = { + fields: FQ.Fields, + filters?: FQ.FilterQuery, + onRemoveFilter?: (index: number) => void, + onRemoveAllFilters: () => void, +}; +export const Filters = (props: FiltersProps) => { + const { + fields, + filters = [], + onRemoveFilter, + onRemoveAllFilters, + } = props; + + const renderDateTimeFilter = (filter: FQ.FieldQuery, index: number) => { + const { fieldName, operatorSymbol, operand } = FQ.decodeFieldQuery(filter, fields); + const field = fieldName ? fields[fieldName] : null; + const fieldNameLabel = typeof field?.label === 'string' ? field?.label : ''; + let symbol = ':'; + + if (field && operatorSymbol && field.type === 'datetime') { + if (operatorSymbol === 'Range') { + symbol = ''; + } else { + symbol = ` ${operatorSymbol}`; + } + } + + let operandLabel: { from: string, to: string } | string = ''; + + if (field && field.type === 'datetime') { + if (operatorSymbol === 'Range') { + if (FQ.isRangeOperationValue(operand)) { + const rangeOperand = operand as [number, number]; + const startDateTime = dateFormat(rangeOperand[0] * 1000, 'MMMM do yyyy HH:mm'); + const endDateTime = dateFormat(rangeOperand[1] * 1000, 'MMMM do yyyy HH:mm'); + operandLabel = { from: startDateTime, to: endDateTime }; + } + } else { + // Here operand should be a number + const singleOperand = operand as number; + const dateTime = dateFormat(singleOperand * 1000, 'MMMM do yyyy HH:mm'); + operandLabel = dateTime; + } + } + + const content: React.ReactNode = fieldNameLabel + ? ( + <> + {`${fieldNameLabel}${symbol}`} + {typeof operandLabel === 'string' + ? {operandLabel} + : ( + <> + from + {operandLabel.from} + to + {operandLabel.to} + + ) + } + + ) : `${operandLabel}`; + + return ( + { onRemoveFilter?.(index); }} + content={content} + /> + ); + }; + + const renderArrayFilter = (filter: FQ.FieldQuery, index: number) => { + const { fieldName, operatorSymbol, operand, subOperatorSymbol = '' } = FQ.decodeFieldQuery(filter, fields); + const field = fieldName ? fields[fieldName] : null; + const subField = field && field.type === 'array' ? field.subfield : null; + const fieldNameLabel = typeof field?.label === 'string' ? field?.label : ''; + let symbol = ':'; + + if (field && operatorSymbol && field.type === 'array') { + symbol = ` ${operatorSymbol} ${subOperatorSymbol}`; + } + + let operandLabel = ''; + + if (subField) { + if (subField.type === 'enum') { + if (Array.isArray(operand)) { + operandLabel = (operand as string[]).map(o => subField.alternatives[o]?.label || o).join(', '); + } else { + operandLabel = subField.alternatives[operand as string]?.label || (operand as string); + } + } else if (subField.type === 'number') { + operandLabel = String(operand); + } + } + + return ( + { onRemoveFilter?.(index); }} + content={ + fieldNameLabel + ? ( + <> + {`${fieldNameLabel}${symbol}`} + {operandLabel} + + ) : `${operandLabel}` + } + /> + ); + }; + + const renderFilter = (filter: FQ.FieldQuery, index: number) => { + const { fieldName, operatorSymbol, operand } = FQ.decodeFieldQuery(filter, fields); + const field = fieldName ? fields[fieldName] : null; + + if (field) { + if (field.type === 'datetime') { + return renderDateTimeFilter(filter, index); + } + if (field.type === 'array') { + return renderArrayFilter(filter, index); + } + } + + const fieldNameLabel = typeof field?.label === 'string' ? field?.label : ''; + let symbol = ':'; + + if (field && operatorSymbol) { + if (field.type === 'number' || field.type === 'enum') { + symbol = ` ${operatorSymbol}`; + } + } + + let operandLabel: string; + + if (field && field.type === 'enum') { + if (Array.isArray(operand)) { + operandLabel = (operand as string[]).map(o => field.alternatives[o]?.label || o).join(', '); + } else { + operandLabel = field.alternatives[operand as string]?.label || (operand as string); + } + } else if (field && field.type === 'dictionary') { + const dictOperand = operand as Record; + operandLabel = Object.keys(dictOperand).map(key => { + const keyLabel = field.suggestedKeys?.[key]?.label ?? key; + return `${keyLabel} = ${String(dictOperand[key])}`; + }).join(', '); + } else { + operandLabel = String(operand); + } + + return ( + { onRemoveFilter?.(index); }} + content={ + fieldNameLabel + ? ( + <> + {`${fieldNameLabel}${symbol}`} + {operandLabel} + + ) : `${operandLabel}` + } + /> + ); + }; + + const renderActions = () => { + return filters.length > 0 && ( +
+ + role="button" + tabIndex={0} + className="clear-all" + onKeyDown={onRemoveAllFilters} + onClick={onRemoveAllFilters} + > + Clear all + +
+ ); + }; + + if (filters.length === 0) { + return null; + } + + return ( +
+
+ {filters.map(renderFilter)} +
+ {renderActions()} +
+ ); +}; + + +// +// Suggestions dropdown +// + +const SuggestionItem = DropdownMenu.Action; + +export type SuggestionProps = Omit, 'children'> & { + children: React.ReactNode | ((props: { close: () => void }) => React.ReactNode), + elementRef?: undefined | React.RefObject, // Helps to toggle multiple dropdowns on the same reference element + active?: undefined | boolean, + withArrow?: undefined | boolean, + primary?: undefined | boolean, + secondary?: undefined | boolean, + basic?: undefined | boolean, + // popperOptions?: undefined | Dropdown.PopperOptions, + onOutsideClick?: undefined | (() => void), + containerRef?: undefined | React.RefObject, +}; +// export const Suggestions = (props: SuggestionProps) => { +// const { +// active = false, +// className = '', +// withArrow = false, +// primary = false, +// secondary = false, +// basic = false, +// children = '', +// elementRef, +// // popperOptions = {}, +// onOutsideClick, +// containerRef, +// } = props; + +// const [isActive, setIsActive] = React.useState(false); + +// const [referenceElement, setReferenceElement] = React.useState(elementRef?.current ?? null); +// const [popperElement, setPopperElement] = React.useState(null); +// const [arrowElement, setArrowElement] = React.useState(null); +// const popper = Popper.usePopper(referenceElement, popperElement, { +// modifiers: [ +// { name: 'arrow', options: { element: arrowElement } }, +// { name: 'preventOverflow', enabled: true }, +// ...(popperOptions.modifiers || []), +// ], +// placement: popperOptions.placement, +// }); + +// React.useEffect(() => { +// if (elementRef?.current) { +// setReferenceElement(elementRef?.current); +// } +// }, [elementRef]); + +// const onClose = () => { +// setIsActive(false); +// }; + +// const dropdownRef = { current: popperElement }; +// const toggleRef = { current: referenceElement }; +// useOutsideClickHandler([dropdownRef, toggleRef, ...(containerRef ? [containerRef] : [])], onOutsideClick ?? onClose); + +// const renderDropdownItems = (dropdownItems: React.ReactElement) => { +// const dropdownChildren = dropdownItems.type === React.Fragment +// ? dropdownItems.props.children +// : dropdownItems; + +// return React.Children.map(dropdownChildren, child => { +// const { onActivate: childOnActivate, onClose: childOnClose } = child.props; + +// return child.type !== SuggestionItem +// ? child +// : React.cloneElement(child, { +// onActivate: (value: string | number) => { childOnActivate(value); }, +// onClose: childOnClose ?? onClose, +// }); +// }); +// }; + +// const renderDropdown = () => { +// return ( +//
+//
    +// {typeof children === 'function' +// ? children({ close: onClose }) +// : renderDropdownItems(children as React.ReactElement) +// } +//
+// {withArrow &&
} +//
+// ); +// }; + +// return ( +// <> +// {(isActive || active) && ReactDOM.createPortal(renderDropdown(), document.body)} +// +// ); +// }; + +export type SearchInputProps = ComponentProps & { + fields: FQ.Fields, + fieldQueryBuffer: FieldQueryBuffer, + inputRef: React.RefObject +}; +export const SearchInput = (props: SearchInputProps) => { + const { + className, + onKeyDown, + fields, + fieldQueryBuffer, + inputRef, + onFocus, + onBlur, + ...restProps + } = props; + + const { + isFocused, + handleFocus, + handleBlur, + } = useFocus({ onFocus, onBlur }); + + const field = fieldQueryBuffer.fieldName ? fields[fieldQueryBuffer.fieldName] : null; + let operator = ':'; + + if (fieldQueryBuffer.operator) { + if (fieldQueryBuffer.operator === '$range') { + operator = ':'; + } else if (field) { + operator = ` ${getOperatorLabel(fieldQueryBuffer.operator, field)}`; + } + } + + const subField = field?.type === 'array' && field.subfield ? field.subfield : null; + let subOperator = ''; + + if (fieldQueryBuffer.subOperator) { + if (fieldQueryBuffer.subOperator === '$range') { + subOperator = ':'; + } else if (subField) { + subOperator = ` ${getOperatorLabel(fieldQueryBuffer.subOperator, subField)}`; + } + } + + let key = ''; + + if (field?.type === 'dictionary' && fieldQueryBuffer.key.trim()) { + key = field.suggestedKeys?.[fieldQueryBuffer.key.trim()]?.label ?? fieldQueryBuffer.key.trim(); + } + + const onWrapperClick = (evt: React.MouseEvent) => { + evt.preventDefault(); + + if (inputRef?.current) { + inputRef.current.click(); + } + }; + + const onWrapperKeyDown = (evt: React.KeyboardEvent) => { + if (evt.key === 'Enter') { + evt.preventDefault(); + + if (inputRef?.current) { + inputRef.current.click(); + } + } + }; + + const renderPlaceholder = () => { + if (field?.type === 'dictionary' && key) { + return `Enter a value for ${key}`; + } + + return field?.placeholder ?? 'Search'; + }; + + return ( +
+ role="button" + tabIndex={0} + className={cx('bk-search-input', className, { 'bk-search-input--active': isFocused })} + onClick={onWrapperClick} + onKeyDown={onWrapperKeyDown} + > + + {field && + + {field.label}{operator}{subOperator} {key ? `${key} =` : ''} + + } + +
+ ); +}; + +type FieldsDropdownProps = { + inputRef?: React.RefObject, + isActive?: boolean, + fields?: FQ.Fields, + // popperOptions?: Dropdown.PopperOptions, + onClick: (fieldName?: string) => void, + onOutsideClick?: () => void, +}; + +const FieldsDropdown = (props: FieldsDropdownProps) => { + const { + inputRef, + isActive = false, + fields, + // popperOptions, + onClick, + onOutsideClick, + } = props; + + if (typeof fields === 'undefined') { + return null; + } + + return ( + <> + // + // {Object.entries(fields || {}).map(([fieldName, { label }]) => ( + // + // {label} + // + // ))} + // + ); +}; + +type AlternativesDropdownProps = { + inputRef?: React.RefObject, + isActive?: boolean, + operators?: FQ.EnumFieldOperator[] | FQ.ArrayFieldOperator[], + alternatives?: FQ.Alternatives, + // popperOptions?: Dropdown.PopperOptions, + selectedOperator: FQ.Operator, + onChange: (value: Primitive[]) => void, + onOutsideClick?: () => void, + validator?: FQ.ArrayValidator, +}; + +const AlternativesDropdown = (props: AlternativesDropdownProps) => { + const { + inputRef, + isActive = false, + operators, + alternatives, + // popperOptions, + onChange, + onOutsideClick, + selectedOperator, + validator, + } = props; + + const [selectedAlternatives, setSelectedAlternatives] = React.useState>([]); + + const canSelectMultipleItems = ['$in', '$nin', '$any', '$all'].includes(selectedOperator); + + const onOptionClick = (context: DropdownMenuContext) => { + if (typeof context.selectedOption !== 'undefined') { + onChange([context.selectedOption]); + } + }; + + const ValidateSelection = () => { + let isValid = false; + let message = ''; + if (selectedAlternatives.length > 0) { + isValid = true; + } + if (typeof validator === 'function' && selectedAlternatives.length > 0) { + const validatorResponse = validator({ buffer: selectedAlternatives }); + isValid = validatorResponse.isValid; + message = validatorResponse.message; + } + + return { isValid, message }; + }; + + const arrayValidation = ValidateSelection(); + + const onSelectionComplete = () => { + onChange(selectedAlternatives); + }; + + const onSelectionChange = (alternativeName: string, shouldBeChecked: boolean) => { + if (shouldBeChecked) { + setSelectedAlternatives([...selectedAlternatives, alternativeName]); + } else { + setSelectedAlternatives([...selectedAlternatives.filter(item => item !== alternativeName)]); + } + }; + + const renderMultiSelectAlternatives = () => ( + <> + + {Object.entries(alternatives || {}).map(([alternativesName, { label }], index) => ( + { onSelectionChange(alternativesName, event.target.checked); }} + /> + ))} + + {!arrayValidation.isValid && arrayValidation.message && ( + + {arrayValidation.message} + + )} +
+ +
+ + ); + + const renderAlternatives = () => ( + Object.entries(alternatives || {}).map(([alternativesName, { label }]) => ( + + )) + ); + + return ( + <> + // + // {canSelectMultipleItems ? renderMultiSelectAlternatives() : renderAlternatives()} + // + ); +}; + +type DateTimeDropdownProps = { + inputRef?: React.RefObject, + isActive?: boolean, + // popperOptions?: Dropdown.PopperOptions, + onChange: (value: number | [number, number]) => void, + onOutsideClick?: () => void, + maxDate?: Date | number | undefined, + minDate?: Date | number | undefined, + selectedDate?: FQ.SelectedDate | undefined, + canSelectDateTimeRange?: boolean | undefined, + validator?: FQ.DateTimeValidator | undefined, +}; + +const DateTimeDropdown = (props: DateTimeDropdownProps) => { + const { + inputRef, + isActive = false, + // popperOptions, + onChange, + onOutsideClick, + maxDate, + minDate, + selectedDate, + canSelectDateTimeRange, + validator, + } = props; + + const dateTimeMeridiemRef = React.useRef(null); + + + const isValidDateParamType = (date: number | Date | undefined) => { + return !!(date && typeof date === 'number' || date instanceof Date); + }; + + const isValidSelectedDate = (selectedDate: FQ.SelectedDate | undefined) => { + if (Array.isArray(selectedDate) && selectedDate.length === 2) { + return isValidDateParamType(selectedDate[0]) && isValidDateParamType(selectedDate[1]); + } + + return isValidDateParamType(selectedDate as number | Date | undefined); + }; + + const getDateObject = (date: Date | number): Date => { + return typeof date === 'number' + ? fromUnixTime(date) + : date; + }; + + const isSingleDate = (date: FQ.SelectedDate): date is FQ.DateType => + typeof date === 'number' || date instanceof Date; + + + const isDateRange = (date: FQ.SelectedDate): date is [FQ.DateType, FQ.DateType] => + Array.isArray(date) && date.length === 2; + + + const initDateTime = (selectedDate: FQ.SelectedDate | undefined, range: 'start' | 'end') => { + const defaultDate = setDate(new Date(), { seconds: 0, milliseconds: 0 }); + if (!selectedDate) { + return defaultDate; + } + + let date = defaultDate; + + // First, check if it's a single date + if (isSingleDate(selectedDate) && isValidDateParamType(selectedDate)) { + // Now it's safe to call isValidDateParamType(selectedDate) + date = getDateObject(selectedDate); + } else if (isValidSelectedDate(selectedDate) && isDateRange(selectedDate)) { + // It's a date range + if (range === 'start') { + date = getDateObject(selectedDate[0]); + } else if (range === 'end') { + date = getDateObject(selectedDate[1]); + } + } + + return date; + }; + + const [dateTime, setDateTime] = React.useState(initDateTime(selectedDate, 'start')); + const [startDateTime, setStartDateTime] = React.useState(initDateTime(selectedDate, 'start')); + const [endDateTime, setEndDateTime] = React.useState(initDateTime(selectedDate, 'end')); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + React.useEffect(() => { + if (isValidSelectedDate(selectedDate)) { + const updatedDateTime = initDateTime(selectedDate, 'start'); + if (!isEqual(updatedDateTime, dateTime)) { + setDateTime(updatedDateTime); + } + + const updatedStartDateTime = initDateTime(selectedDate, 'start'); + if (!isEqual(updatedStartDateTime, startDateTime)) { + setStartDateTime(updatedStartDateTime); + } + + const updatedEndDateTime = initDateTime(selectedDate, 'end'); + if (!isEqual(updatedEndDateTime, endDateTime)) { + setEndDateTime(updatedEndDateTime); + } + } + }, [selectedDate]); + + const onSelectionComplete = () => { + if (canSelectDateTimeRange) { + onChange([getUnixTime(startDateTime), getUnixTime(endDateTime)]); + } else { + onChange(getUnixTime(dateTime)); + } + }; + + const validateDateTimeRange = (): { isValid: boolean, message: string } => { + let isValid = false; + let message = ''; + + if (canSelectDateTimeRange) { + if (minDate) { + isValid = isDateEqual(startDateTime, new Date(minDate)) + || (isDateAfter(startDateTime, new Date(minDate)) && isDateAfter(endDateTime, new Date(minDate))); + } + + if (maxDate) { + isValid = isDateEqual(endDateTime, new Date(maxDate)) + || (isDateBefore(endDateTime, new Date(maxDate)) && isDateBefore(startDateTime, new Date(maxDate))); + } + + if (!minDate && !maxDate) { + isValid = true; + } + + if (isValid) { + isValid = isDateBefore(startDateTime, endDateTime) || isDateEqual(startDateTime, endDateTime); + if (!isValid) { + message = 'End date cannot be before the start date'; + } + } + } else { + if (minDate) { + isValid = isDateEqual(dateTime, new Date(minDate)) || isDateAfter(dateTime, new Date(minDate)); + } + + if (maxDate) { + isValid = isDateEqual(dateTime, new Date(maxDate)) || isDateBefore(dateTime, new Date(maxDate)); + } + + if (!minDate && !maxDate) { + isValid = true; + } + } + + if (isValid && typeof validator === 'function') { + const validatorResponse = validator({ dateTime, startDateTime, endDateTime }); + isValid = validatorResponse.isValid; + message = validatorResponse.message; + } + + return { isValid, message }; + }; + + const dateTimeRangeValidation = validateDateTimeRange(); + + const renderDateTimeRangePicker = () => ( + <> +
+
Start Date
+ {/* */} +
+ +
+
End Date
+ {/* */} +
+ + {!dateTimeRangeValidation.isValid + && dateTimeRangeValidation.message + && ( + + {dateTimeRangeValidation.message} + + ) + } + +
+ +
+ + ); + + const renderDateTimePicker = () => ( + <> +
+ {/* */} +
+ +
+ +
+ + ); + + return ( + <> + // + // {canSelectDateTimeRange ? renderDateTimeRangePicker() : renderDateTimePicker()} + // + ); +}; + +type SuggestedKeysDropdownProps = { + inputRef?: React.RefObject, + isActive?: boolean, + operators?: FQ.DictionaryFieldOperators[], + suggestedKeys?: FQ.SuggestedKeys | undefined, + // popperOptions?: Dropdown.PopperOptions, + onChange: (value: string) => void, + onOutsideClick?: () => void, +}; + +const SuggestedKeysDropdown = (props: SuggestedKeysDropdownProps) => { + const { + inputRef, + isActive = false, + operators, + suggestedKeys, + // popperOptions, + onChange, + onOutsideClick, + } = props; + + const [suggestedKeyValue, setSuggestedKeyValue] = React.useState(''); + + const onOptionClick = (dropdownContext?: DropdownMenuContext) => { + if (typeof dropdownContext?.selectedOption !== 'undefined') { + onChange(dropdownContext.selectedOption as string); + } + }; + + const onInputChange = (event: React.ChangeEvent) => { + const key = event.target.value; + setSuggestedKeyValue(key); + }; + + const onInputKeyDown = (evt: React.KeyboardEvent) => { + if (evt.key === 'Enter' && suggestedKeyValue !== '') { + onChange(suggestedKeyValue); + setSuggestedKeyValue(''); + } + }; + + const renderSuggestedKeys = () => ( + Object.entries(suggestedKeys || {}).map(([suggestedKey, { label }]) => ( + + )) + ); + + const renderSuggestedKeyInput = () => ( + + ); + + return ( + <> + // + // {renderSuggestedKeys()} + // {renderSuggestedKeyInput()} + // + ); +}; + +type OperatorsDropdownProps = { + type: FQ.Field['type'], + inputRef: React.RefObject, + isActive: boolean, + operators: Array, + // popperOptions?: Dropdown.PopperOptions, + onClick: (conext: DropdownMenuContext) => void, + onOutsideClick?: () => void, + operatorInfo?: FQ.OperatorInfo | undefined, +}; + +const OperatorsDropdown = (props: OperatorsDropdownProps) => { + const { + type, + inputRef, + isActive = false, + operators, + // popperOptions, + onClick, + onOutsideClick, + operatorInfo = {}, + } = props; + + if (operators.length === 0) { return null; } + + let symbolMap = {}; + + if (type === 'enum') { + symbolMap = enumOperatorsToSymbolMap; + } else if (type === 'array') { + symbolMap = arrayOperatorsToSymbolMap; + } else if (type === 'number') { + symbolMap = numberOperatorsToSymbolMap; + } else if (type === 'datetime') { + symbolMap = dateTimeFieldOperatorsToSymbolMap; + } + + symbolMap = ObjectUtil.map(symbolMap, (label, operator) => { + return operator in operatorInfo + ? operatorInfo[operator as FQ.Operator]?.label + : label; + }); + + return ( + <> + // + // {ObjectUtil.entries(symbolMap) + // .filter(entry => operators.includes(entry[0])) + // .map(([operator, operatorSymbol]) => ( + // + // {operatorSymbol} + // + // )) + // } + // + ); +}; + +type FieldQueryBuffer = { + fieldName: FQ.FieldName, + operator: FQ.Operator | null, + subOperator: FQ.Operator | null, + key: string, + value: string, +}; + +export const initializeFieldQueryBuffer = (): FieldQueryBuffer => ({ + fieldName: '', + operator: null, + subOperator: null, + key: '', + value: '', +}); + +export type MultiSearchProps = Omit, 'className'|'children'> & { + className?: ClassNameArgument, + fields: FQ.Fields, + // popperOptions?: Dropdown.PopperOptions, + query?: (filters: FQ.FilterQuery) => void; + filters?: FQ.FilterQuery, +}; +export const MultiSearch = (props: MultiSearchProps) => { + const inputRef = React.useRef(null); + + const { + className, + fields, + // popperOptions: customPopperOptions, + query = () => {}, + onFocus, + onClick, + disabled, + filters: customFilters = FQ.createFilterQuery(), + } = props; + + const { filters, addFilter, removeFilter, removeAllFilters } = useFilters({ + fields, + customFilters, + query, + }); + const [fieldQueryBuffer, setFieldQueryBuffer] = React.useState(initializeFieldQueryBuffer); + const [isInputFocused, setIsInputFocused] = React.useState(false); + const [validatorResponse, setValidatorResponse] = React.useState({ isValid: true, message: '' }); + + // const popperOptions: Dropdown.PopperOptions = { + // placement: 'bottom-start', + // ...(customPopperOptions ?? {}), + // }; + + const updateFieldQueryBuffer = (newFieldQuery: FieldQueryBuffer) => { + setFieldQueryBuffer(newFieldQuery); + }; + + const validateFieldQuery = (fieldQueryBuffer: FieldQueryBuffer): FQ.ValidatorResponse => { + let isValid = fieldQueryBuffer.value?.trim() !== ''; + let message = ''; + + if (fieldQueryBuffer.fieldName) { + // biome-ignore lint/style/noNonNullAssertion: + const field: FQ.Field = fields[fieldQueryBuffer.fieldName]!; + if (field.type === 'text') { + const searchInputValidator = field.validator as FQ.TextValidator; + if (isValid && typeof searchInputValidator === 'function') { + const validatorResponse = searchInputValidator({ buffer: fieldQueryBuffer.value }); + isValid = validatorResponse.isValid; + message = validatorResponse.message; + } + } else if (field.type === 'number') { + const searchInputValidator = field.validator as FQ.TextValidator; + if (isValid) { + const inputNumber = Number(fieldQueryBuffer.value); + if (Number.isNaN(inputNumber) || !Number.isFinite(inputNumber)) { + isValid = false; + message = 'Please enter a valid value'; + } else if (typeof searchInputValidator === 'function') { + const validatorResponse = searchInputValidator({ buffer: fieldQueryBuffer.value }); + isValid = validatorResponse.isValid; + message = validatorResponse.message; + } + } + } else if (field.type === 'array') { + const searchInputValidator = field.validator as FQ.ArrayValidator; + if (isValid) { + if (field.subfield && field.subfield.type === 'enum') { + isValid = Object.values(field.subfield.alternatives).filter(alternative => + alternative.label.toLowerCase() === fieldQueryBuffer.value.toLowerCase()).length > 0; + if (!isValid) { + message = 'Please enter a valid value'; + } + } else if (typeof searchInputValidator === 'function') { + const validatorResponse = searchInputValidator({ buffer: [fieldQueryBuffer.value] }); + isValid = validatorResponse.isValid; + message = validatorResponse.message; + } + } + } else if (field.type === 'enum') { + const searchInputValidator = field.validator as FQ.EnumValidator; + if (isValid) { + isValid = Object.values(field.alternatives).filter(alternative => + alternative.label.toLowerCase() === fieldQueryBuffer.value.toLowerCase()).length > 0; + if (!isValid) { + message = 'Please enter a valid value'; + } else if (typeof searchInputValidator === 'function') { + const validatorResponse = searchInputValidator({ buffer: fieldQueryBuffer.value }); + isValid = validatorResponse.isValid; + message = validatorResponse.message; + } + } + } else if (field.type === 'dictionary') { + const searchInputValidator = field.validator as FQ.DictionaryValidator; + if (typeof searchInputValidator === 'function') { + const validatorResponse = searchInputValidator({ key: fieldQueryBuffer.key, buffer: fieldQueryBuffer.value }); + isValid = validatorResponse.isValid; + message = validatorResponse.message; + } else { + // This is needed for custom attribute + // where we just want to search based on attribute key (disregarding the value) + isValid = true; + } + } else if (field.type === 'datetime') { + const searchInputValidator = field.validator as FQ.DateTimeValidator; + if (isValid) { + const dateTime = new Date(fieldQueryBuffer.value); + if (Number.isNaN(dateTime.valueOf())) { + isValid = false; + message = 'Please enter a valid value'; + } else if (typeof searchInputValidator === 'function') { + const validatorResponse = searchInputValidator({ + dateTime, + startDateTime: new Date(), + endDateTime: new Date(), + }); + isValid = validatorResponse.isValid; + message = validatorResponse.message; + } + } + } + } + setValidatorResponse({ isValid, message }); + return { isValid, message }; + }; + + const onInputKeyDown = (evt: React.KeyboardEvent) => { + if (evt.key === 'Enter') { + evt.preventDefault(); + + const validatorResponse = validateFieldQuery(fieldQueryBuffer); + if (validatorResponse.isValid) { + let fieldValue: string | string[] | number = fieldQueryBuffer.value; + if (fieldQueryBuffer?.fieldName) { + // biome-ignore lint/style/noNonNullAssertion: + const field = fields[fieldQueryBuffer.fieldName]!; + if (field.type === 'enum' || (field.type === 'array' && field.subfield?.type === 'enum')) { + fieldValue = [fieldQueryBuffer.value]; + } else if (field.type === 'datetime') { + fieldValue = getUnixTime(new Date(fieldQueryBuffer.value)); + } + } + addFilter({ + fieldName: fieldQueryBuffer.fieldName, + value: fieldValue, + selectedOperator: fieldQueryBuffer.operator, + selectedSubOperator: fieldQueryBuffer.subOperator, + key: fieldQueryBuffer.key, + }); + updateFieldQueryBuffer(initializeFieldQueryBuffer()); + } + } else if (evt.key === 'Backspace' && fieldQueryBuffer.value === '' && fieldQueryBuffer.fieldName) { + evt.preventDefault(); + + if (fieldQueryBuffer.key) { + updateFieldQueryBuffer({ ...fieldQueryBuffer, key: '' }); + } else if (fieldQueryBuffer.subOperator && FQ.operators.includes(fieldQueryBuffer.subOperator)) { + updateFieldQueryBuffer({ ...fieldQueryBuffer, subOperator: null }); + } else if (fieldQueryBuffer.operator && FQ.operators.includes(fieldQueryBuffer.operator)) { + updateFieldQueryBuffer({ ...fieldQueryBuffer, operator: null, subOperator: null }); + } else { + updateFieldQueryBuffer(initializeFieldQueryBuffer()); + } + + validateFieldQuery(fieldQueryBuffer); + } + }; + + const onInputChange = (evt: React.ChangeEvent) => { + const value = (evt.currentTarget as HTMLInputElement).value; + updateFieldQueryBuffer({ ...fieldQueryBuffer, value }); + if (value === '') { + setValidatorResponse({ isValid: true, message: '' }); + } + }; + + const onSearchInputFocus = (evt: React.FocusEvent) => { + setIsInputFocused(true); + onFocus?.(evt); + }; + + const onOutsideClick = () => { + setIsInputFocused(false); + }; + + const renderSearchInput = () => ( + + ); + + const renderFieldsDropdown = () => { + const isActive = isInputFocused && !fieldQueryBuffer.fieldName && fieldQueryBuffer.value === ''; + + const onFieldClick = (fieldName?: string) => { + if (!fieldName) { return; } + + const field = fields[fieldName]; + + if (!field) { + return null; + } + + const newFieldQuery: FieldQueryBuffer = { + ...fieldQueryBuffer, + fieldName, + }; + + if (['number', 'datetime', 'enum', 'array'].includes(field.type) && field.operators.length === 1) { + // biome-ignore lint/style/noNonNullAssertion: + newFieldQuery.operator = field.operators[0]!; + } + + if (field.type === 'array' && field.subfield.operators.length === 1) { + // biome-ignore lint/style/noNonNullAssertion: + newFieldQuery.subOperator = field.subfield.operators[0]!; + } + + updateFieldQueryBuffer(newFieldQuery); + + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + return ( + + ); + }; + + const renderAlternativesDropdown = () => { + const { fieldName, operator, subOperator } = fieldQueryBuffer; + + if (!fieldName || !operator) { return null; } + + const field = fields[fieldName]; + + if (!field) { + return null; + } + + let alternatives = {}; + + if (field.type === 'enum') { + alternatives = field.alternatives; + } else if (field.type === 'array' && field.subfield.type === 'enum') { + alternatives = field.subfield.alternatives; + } + + if (!Object.keys(alternatives).length) { + return null; + } + + let operators: Array | Array = []; + + if (field.operators) { + operators = field.operators as Array | Array; + } + + const isActive = isInputFocused + && field + && ((field.type === 'enum' && !!operator) + || (field.type === 'array' && !!operator && (!!subOperator || ['$eq', '$ne'].includes(operator))) + ) + && fieldQueryBuffer.value === ''; + + const onAlternativesChange = (value: Primitive[]) => { + addFilter({ + fieldName, + value, + selectedOperator: operator, + selectedSubOperator: subOperator, + }); + updateFieldQueryBuffer(initializeFieldQueryBuffer()); + + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + return ( + } + /> + ); + }; + + const renderDateTimeSelectorDropdown = () => { + const { fieldName, operator } = fieldQueryBuffer; + + if (!fieldName) { return null; } + + const field = fields[fieldName]; + + if (field?.type !== 'datetime') { return null; } + + const isActive = isInputFocused + && field + && !!operator + && fieldQueryBuffer.value === ''; + + const onDateTimeRangeChange = (value: number | [number, number]) => { + addFilter({ fieldName, value, selectedOperator: operator }); + + updateFieldQueryBuffer(initializeFieldQueryBuffer()); + + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + const canSelectDateTimeRange = () => { + return operator === '$range'; + }; + + return ( + + ); + }; + + const renderSuggestedKeysDropdown = () => { + const { fieldName } = fieldQueryBuffer; + + if (!fieldName) { return null; } + + const field = fields[fieldName]; + + if (field?.type !== 'dictionary') { return null; } + + const isActive = isInputFocused && field && fieldQueryBuffer.value === '' && fieldQueryBuffer.key === ''; + + const onSuggestedKeysChange = (key: string) => { + updateFieldQueryBuffer({ ...fieldQueryBuffer, key }); + + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + return ( + + ); + }; + + const renderOperatorsDropdown = () => { + const { fieldName } = fieldQueryBuffer; + + if (!fieldName) { return null; } + + const field = fields[fieldName]; + + const operatorTypes = ['number', 'datetime', 'enum', 'array']; + + if (!field + || (field.type !== 'number' + && field.type !== 'datetime' + && field.type !== 'enum' + && field.type !== 'array' + ) + ) { return null; } + + const isFieldSupported = field && operatorTypes.includes(field.type); + + const isActive = isInputFocused + && isFieldSupported + && !fieldQueryBuffer.operator + // If only one operator is supported, then no need to show dropdown. + && field.operators.length > 1 + && fieldQueryBuffer.value === ''; + + const onOperatorClick = ( + dropdownMenuContext: DropdownMenuContext, + ) => { + if (typeof dropdownMenuContext?.selectedOption === 'undefined') { return; } + + const newFieldQuery = { ...fieldQueryBuffer, operator: dropdownMenuContext.selectedOption as FQ.Operator }; + + if (field.type === 'array' && field.subfield.operators.length === 1) { + // biome-ignore lint/style/noNonNullAssertion: + newFieldQuery.subOperator = field.subfield.operators[0]!; + } + + updateFieldQueryBuffer(newFieldQuery); + + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + return ( + + ); + }; + + const renderSubOperatorsDropdown = () => { + const { fieldName, operator } = fieldQueryBuffer; + + if (!fieldName) { return null; } + + const field = fields[fieldName]; + + const operatorTypes = ['array']; + + if (!field || field.type !== 'array') { return null; } + + const subOperatorTypes = ['enum', 'number']; + const subField = field.subfield; + + if (!subField || (subField.type !== 'number' && subField.type !== 'enum')) { + return null; + } + + const isFieldSupported = subField && subOperatorTypes.includes(subField.type); + + const isActive = isInputFocused + && isFieldSupported + && !!operator + && !['$eq', '$ne'].includes(operator) + && !fieldQueryBuffer.subOperator + // If only one sub operator is supported, then no need to show dropdown. + && subField.operators.length > 1 + && fieldQueryBuffer.value === ''; + + const onOperatorClick = ( + dropdownMenuContext: DropdownMenuContext, + ) => { + if (typeof dropdownMenuContext.selectedOption === 'undefined') { return; } + + updateFieldQueryBuffer({ ...fieldQueryBuffer, subOperator: dropdownMenuContext.selectedOption as FQ.Operator }); + + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + return ( + op !== '$eq' && op !== '$ne') + : subField.operators} + // popperOptions={popperOptions} + onClick={onOperatorClick} + onOutsideClick={onOutsideClick} + operatorInfo={field.subfield.operatorInfo} + /> + ); + }; + + return ( +
+ {renderSearchInput()} + {renderFieldsDropdown()} + {renderAlternativesDropdown()} + {renderDateTimeSelectorDropdown()} + {renderSuggestedKeysDropdown()} + {renderOperatorsDropdown()} + {renderSubOperatorsDropdown()} + {!validatorResponse.isValid && validatorResponse.message && ( + + {validatorResponse.message} + + )} + +
+ ); +}; diff --git a/src/components/tables/MultiSearch/filterQuery.ts b/src/components/tables/MultiSearch/filterQuery.ts new file mode 100644 index 00000000..48ed7f95 --- /dev/null +++ b/src/components/tables/MultiSearch/filterQuery.ts @@ -0,0 +1,682 @@ +/* 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/. */ + +// +// This module contains definitions for the filter query language used to express filtering in +// data tables. These filters can be sent to a backend API to filter the result set. +// + +// Utilities +type ValueOf> = T[number]; + +const uniq = (arr: ReadonlyArray): Array => [...new Set(arr)]; + + +// Operators +export const enumFieldOperators = ['$in', '$nin', '$eq', '$ne'] as const; +export type EnumFieldOperator = ValueOf; + +export const arrayFieldOperators = ['$eq', '$ne', '$all', '$any'] as const; +export type ArrayFieldOperator = ValueOf; + +export const textFieldOperators = ['$eq', '$text'] as const; +export type TextFieldOperator = ValueOf; + +export const numberFieldOperators = ['$eq', '$gt', '$gte', '$lt', '$lte', '$ne'] as const; +export type NumberFieldOperator = ValueOf; + +export const dictionaryFieldOperators = ['$all'] as const; +export type DictionaryFieldOperators = ValueOf; + +export const recordFieldOperators = ['$all', '$any'] as const; +export type RecordFieldOperators = ValueOf; + +export const dateTimeFieldOperators = ['$eq', '$gt', '$gte', '$lt', '$lte', '$ne', '$range'] as const; +export type DateTimeFieldOperator = ValueOf; + +// A union of all operators +export type Operator = + | EnumFieldOperator + | ArrayFieldOperator + | TextFieldOperator + | NumberFieldOperator + | DictionaryFieldOperators + | RecordFieldOperators + | DateTimeFieldOperator; + +export const operators: Operator[] = uniq([ + ...enumFieldOperators, + ...arrayFieldOperators, + ...textFieldOperators, + ...numberFieldOperators, + ...dictionaryFieldOperators, + ...dateTimeFieldOperators, +] as const); + +// Field specification +export type Alternative = { label: string }; +export type Alternatives = Record; +export type OperatorInfo = Partial>; +export type TypeOfFieldSpec = + S extends { type: 'number' } + ? number + : S extends { type: 'text' } + ? string + : S extends { type: 'datetime' } + ? Date + : S extends { type: 'enum' } + ? keyof S['alternatives'] + : S extends { type: 'array' } + ? Array> + : S extends { type: 'dictionary' } + ? Record + : S extends { type: 'record' } + ? TypeOfFieldsSpec + : never; + +export type TypeOfFieldsSpec = { + [fieldName in keyof S]: TypeOfFieldSpec +}; + +export type ValidatorResponse = { + isValid: boolean, + message: string, +}; +export type DateTimeValidator = (param: { + dateTime: Date, + startDateTime: Date, + endDateTime: Date +}) => ValidatorResponse; +export type TextValidator = (options: { buffer: string }) => ValidatorResponse; +export type ArrayValidator = + (options: { buffer: TypeOfFieldSpec }) => ValidatorResponse; +export type EnumValidator = + (options: { buffer: TypeOfFieldSpec }) => ValidatorResponse; +export type DictionaryValidator = (options: { key: string, buffer: string }) => ValidatorResponse; + +export type Accessor = (item: unknown) => R; + +type BaseFieldSpec = { + label: React.ReactNode, + placeholder?: string, + operatorInfo?: OperatorInfo, + onAddFilter?: OnAddFilter, + accessor?: Accessor, +} + +export type EnumFieldSpec = BaseFieldSpec & { + type: 'enum', + operators: Array, + alternatives: Alternatives, + validator?: EnumValidator, + accessor?: Accessor, +}; +export type ArrayFieldSpec = BaseFieldSpec & { + type: 'array', + operators: Array, + subfield: EnumFieldSpec | NumberFieldSpec, + validator?: ArrayValidator, + accessor?: Accessor, +}; +export type TextFieldSpec = BaseFieldSpec & { + type: 'text', + operators: Array, + validator?: TextValidator, + accessor?: Accessor, +}; +export type NumberFieldSpec = BaseFieldSpec & { + type: 'number', + operators: Array, + validator?: TextValidator, + accessor?: Accessor, +}; + +export type DateType = Date | number; +export type SelectedDate = DateType | [DateType, DateType]; +export type OnAddFilter = (newFilter: FieldQuery, currentFilters: FilterQuery) => FilterQuery; + +export type DateTimeFieldSpec = BaseFieldSpec & { + type: 'datetime', + operators: Array, + selectedDate?: SelectedDate, + onAddFilter?: OnAddFilter, + maxDate?: Date | number, + minDate?: Date | number, + validator?: DateTimeValidator, + accessor?: Accessor, +}; +type SuggestedKey = { label: string }; +export type SuggestedKeys = { [key: string]: SuggestedKey }; +export type DictionaryFieldSpec = BaseFieldSpec & { + type: 'dictionary', + operators: Array, + suggestedKeys?: SuggestedKeys, + validator?: DictionaryValidator, + accessor?: Accessor>, +}; +export type RecordFieldSpec = BaseFieldSpec & { + type: 'record', + operators: Array, + fields: Fields, + validator?: DictionaryValidator, + accessor?: Accessor>, +}; + +export type Field = + | EnumFieldSpec + | ArrayFieldSpec + | TextFieldSpec + | NumberFieldSpec + | DateTimeFieldSpec + | DictionaryFieldSpec + | RecordFieldSpec; +export type Fields = Record; + +type Primitive = null | string | number | bigint | boolean; +type RangeOperationValue = [start: number, end: number]; +type QueryOperation = + | { $eq: Primitive | Array } + | { $ne: Primitive | Array } + | { $in: Array } + | { $nin: Array } + | { $text: { $search: string } } + | { $lt: number } + | { $lte: number } + | { $gt: number } + | { $gte: number } + | { $range: RangeOperationValue } + | { $all: ( + // For dictionary type fields + | { [key: string]: Primitive | QueryOperation } + // For array type fields + //| QueryOperation // Equivalent to `{ $and: [] }` + | { $or: Array } + | { $and: Array } + )} + | { $any: ( + // For dictionary type fields + | { [key: string]: Primitive | QueryOperation } // TODO: not yet implemented in the UI + // For array type fields + | { $or: Array } + | { $and: Array } + )}; + +type EnumFieldQueryOperation = Extract>; +type ArrayFieldQueryOperation = Extract>; +type NumberFieldQueryOperation = Extract>; +type DateTimeFieldQueryOperation = Extract>; + +export type FieldName = string | null; +export type FieldQuery = { fieldName: FieldName, operation: QueryOperation }; +export type FilterQuery = Array; + +export const createFilterQuery = (): FilterQuery => []; + +export const isRangeOperationValue = (input: unknown): input is RangeOperationValue => { + return Array.isArray(input) && input.length === 2 && typeof input[0] === 'number' && typeof input[1] === 'number'; +}; + +// Dummy symbol maps and label function to avoid errors +const enumOperatorsToSymbolMap: Record = { + '$in': 'in', + '$nin': 'nin', + '$eq': '=', + '$ne': '!=' +}; + +const numberOperatorsToSymbolMap: Record = { + '$eq': '=', + '$gt': '>', + '$gte': '>=', + '$lt': '<', + '$lte': '<=', + '$ne': '!=' +}; + +const dateTimeFieldOperatorsToSymbolMap: Record = { + '$eq': '=', + '$gt': '>', + '$gte': '>=', + '$lt': '<', + '$lte': '<=', + '$ne': '!=', + '$range': 'range' +}; + +// Dummy implementation of getOperatorLabel to avoid type errors +function getOperatorLabel(op: Operator, _field?: Field): string { + return op; +} + +const isValidOperator = (operator: Operator, type?: Field['type']) => { + let isValid = false; + + switch (type) { + case 'enum': + isValid = (enumFieldOperators as ReadonlyArray).includes(operator); + break; + + case 'array': + isValid = (arrayFieldOperators as ReadonlyArray).includes(operator); + break; + + case 'dictionary': + isValid = (dictionaryFieldOperators as ReadonlyArray).includes(operator); + break; + + case 'number': + isValid = (numberFieldOperators as ReadonlyArray).includes(operator); + break; + + case 'text': + isValid = (textFieldOperators as ReadonlyArray).includes(operator); + break; + + case 'datetime': + isValid = (dateTimeFieldOperators as ReadonlyArray).includes(operator); + break; + + default: + isValid = (enumFieldOperators as ReadonlyArray).includes(operator) + || (textFieldOperators as ReadonlyArray).includes(operator) + || (numberFieldOperators as ReadonlyArray).includes(operator); + break; + } + + return isValid; +}; + +const encodeEnumFieldQueryOperation = ( + operators: EnumFieldOperator[], + value: Array, + selectedOperator: EnumFieldOperator = '$in', +) => { + if (value.length === 0) { return null; } + + let queryOperation: QueryOperation; + + if (operators.includes('$in') && selectedOperator === '$in') { + queryOperation = { $in: value }; + } else if (operators.includes('$nin') && selectedOperator === '$nin') { + queryOperation = { $nin: value }; + } else if (operators.includes('$ne') && selectedOperator === '$ne') { + // biome-ignore lint/style/noNonNullAssertion: + queryOperation = { $ne: value[0]! }; + } else { + // Default to $eq + // biome-ignore lint/style/noNonNullAssertion: + queryOperation = { $eq: value[0]! }; + } + + return queryOperation; +}; + +// Type guard to check if an operator is a NumberFieldOperator +function isNumberFieldOperator(op: Operator): op is NumberFieldOperator { + return (numberFieldOperators as ReadonlyArray).includes(op); +} + +const encodeArrayFieldQueryOperation = ( + operators: ArrayFieldOperator[], + value: Array | Primitive, + selectedOperator: ArrayFieldOperator, + selectedSubOperator: EnumFieldOperator | NumberFieldOperator | null, +): QueryOperation | null => { + if (Array.isArray(value) && value.length === 0) { return null; } + + let queryOperation: QueryOperation; + + if (operators.includes('$ne') && selectedOperator === '$ne') { + queryOperation = { $ne: value }; + } else if (operators.includes('$any') && selectedOperator === '$any' && selectedSubOperator) { + if (selectedSubOperator === '$in' && Array.isArray(value)) { + queryOperation = { $any: { $or: value.map(v => ({ $eq: v })) } }; + } else if (selectedSubOperator === '$nin' && Array.isArray(value)) { + queryOperation = { $any: { $and: value.map(v => ({ $ne: v })) } }; + } else if (isNumberFieldOperator(selectedSubOperator) && typeof value === 'string') { + const valueAsNumber = Number.parseFloat(value.trim().replace(/[ ,]+/g, '')); + queryOperation = { $any: { [selectedSubOperator]: valueAsNumber } }; + } else { + queryOperation = { $eq: value }; + } + } else if (operators.includes('$all') && selectedOperator === '$all' && selectedSubOperator) { + if (selectedSubOperator === '$in' && Array.isArray(value)) { + queryOperation = { $all: { $or: value.map(v => ({ $eq: v })) } }; + } else if (selectedSubOperator === '$nin' && Array.isArray(value)) { + queryOperation = { $all: { $and: value.map(v => ({ $ne: v })) } }; + } else if (isNumberFieldOperator(selectedSubOperator) && typeof value === 'string') { + const valueAsNumber = Number.parseFloat(value.trim().replace(/[ ,]+/g, '')); + queryOperation = { $all: { [selectedSubOperator]: valueAsNumber } }; + } else { + queryOperation = { $eq: value }; + } + } else { + // Default to $eq + queryOperation = { $eq: value }; + } + + return queryOperation; +}; + +const encodeDictionaryFieldQueryOperation = ( + operators: DictionaryFieldOperators[], + value = '', + key = '', +): QueryOperation => { + return { $all: { [key]: value } }; +}; + +const encodeTextFieldQueryOperation = ( + operators: TextFieldOperator[], + value = '', +) => { + if (value.length === 0) { return null; } + + let queryOperation: QueryOperation; + + if (operators.includes('$text')) { + queryOperation = { $text: { $search: value } }; + } else { + // Default to $eq + queryOperation = { $eq: value }; + } + + return queryOperation; +}; + +const encodeNumberFieldQueryOperation = ( + operators: NumberFieldOperator[], + value: number, + selectedOperator: NumberFieldOperator | null = null, +) => { + let queryOperation: QueryOperation; + + if (selectedOperator === '$lt') { + queryOperation = { $lt: value }; + } else if (selectedOperator === '$lte') { + queryOperation = { $lte: value }; + } else if (selectedOperator === '$gt') { + queryOperation = { $gt: value }; + } else if (selectedOperator === '$gte') { + queryOperation = { $gte: value }; + } else if (selectedOperator === '$ne') { + queryOperation = { $ne: value }; + } else { + // Default to $eq + queryOperation = { $eq: value }; + } + + return queryOperation; +}; + +const encodeDateTimeFieldQueryOperation = ( + operators: DateTimeFieldOperator[], + value: number | RangeOperationValue, + selectedOperator: DateTimeFieldOperator | null = null, +) => { + let queryOperation: QueryOperation | null = null; + if (isRangeOperationValue(value)) { + if (!value[0] || !value[1]) { + return null; + } + if (operators.includes('$range')) { + queryOperation = { $range: value }; + } + } else if (selectedOperator === '$lt') { + queryOperation = { $lt: value }; + } else if (selectedOperator === '$lte') { + queryOperation = { $lte: value }; + } else if (selectedOperator === '$gt') { + queryOperation = { $gt: value }; + } else if (selectedOperator === '$gte') { + queryOperation = { $gte: value }; + } else if (selectedOperator === '$ne') { + queryOperation = { $ne: value }; + } else { + // Default to $eq + queryOperation = { $eq: value }; + } + + return queryOperation; +}; + +export const encodeFieldQuery = ( + fieldName: FieldName, + value: Primitive | Array, + selectedOperator: Operator | null = null, + selectedSubOperator: Operator | null = null, + fields?: Fields, + key?: string, +): FieldQuery | null => { + let operation: QueryOperation | null = null; + const field = fieldName ? fields?.[fieldName] : null; + + if (selectedOperator && !isValidOperator(selectedOperator, field?.type)) { return null; } + + if (field?.type === 'enum' && Array.isArray(value)) { + operation = encodeEnumFieldQueryOperation( + field.operators, + value, + selectedOperator as EnumFieldOperator, + ); + } else if (field?.type === 'array') { + operation = encodeArrayFieldQueryOperation( + field.operators, + value, + selectedOperator as ArrayFieldOperator, + selectedSubOperator as NumberFieldOperator | EnumFieldOperator, + ); + } else if (field?.type === 'dictionary' && typeof value === 'string' && typeof key === 'string') { + operation = encodeDictionaryFieldQueryOperation( + field.operators, + value, + key, + ); + } else if (field?.type === 'text' && typeof value === 'string') { + operation = encodeTextFieldQueryOperation( + field.operators, + value.trim(), + ); + } else if (field?.type === 'number' && typeof value === 'string') { + // Remove comma and space from the value + const valueAsNumber = Number.parseFloat(value.trim().replace(/[ ,]+/g, '')); + + if (!Number.isNaN(valueAsNumber) && Number.isFinite(valueAsNumber)) { + operation = encodeNumberFieldQueryOperation( + field.operators, + valueAsNumber, + selectedOperator as NumberFieldOperator, + ); + } + } else if (field?.type === 'datetime' && (typeof value === 'number' || isRangeOperationValue(value))) { + operation = encodeDateTimeFieldQueryOperation( + field.operators, + value as number | RangeOperationValue, + selectedOperator as DateTimeFieldOperator, + ); + } else if (field === null && typeof value === 'string') { + operation = encodeTextFieldQueryOperation( + ['$text'], + value.trim(), + ); + } + + if (!operation) { return null; } + + return { fieldName, operation }; +}; + +const decodeEnumFieldQuery = (fieldQuery: FieldQuery) => { + const operation = fieldQuery.operation as EnumFieldQueryOperation; + const operator = Object.keys(operation)[0] as EnumFieldOperator; + const operatorSymbol = enumOperatorsToSymbolMap[operator]; + + const operand = Object.values(fieldQuery.operation)[0]; + + return { + fieldName: fieldQuery.fieldName, + operator, + operatorSymbol, + operand, + }; +}; + +const decodeArrayFieldQuery = (fieldQuery: FieldQuery, field: ArrayFieldSpec) => { + const operation = fieldQuery.operation as ArrayFieldQueryOperation; + const operator = Object.keys(operation)[0] as ArrayFieldOperator; + const operatorSymbol = getOperatorLabel(operator, field); + let subOperatorSymbol = ''; + + let operand: unknown = []; + + if (operator === '$any' && '$any' in operation) { + const anyValue = operation.$any; + if ('$or' in anyValue && Array.isArray(anyValue.$or)) { + const values = anyValue.$or; + operand = values.map(value => { + if (typeof value === 'object' && value !== null && '$eq' in value) { + subOperatorSymbol = getOperatorLabel('$in', field.subfield); + return (value as { $eq: unknown }).$eq; + } + return value; + }); + } else if ('$and' in anyValue && Array.isArray(anyValue.$and)) { + const values = anyValue.$and; + operand = values.map(value => { + if (typeof value === 'object' && value !== null && '$ne' in value) { + subOperatorSymbol = getOperatorLabel('$nin', field.subfield); + return (value as { $ne: unknown }).$ne; + } + return value; + }); + } else { + const subKeys = Object.keys(anyValue); + if (subKeys.length > 0) { + // biome-ignore lint/style/noNonNullAssertion: + const subOperator = subKeys[0]!; // non-null assertion + const subVal = (anyValue as Record)[subOperator]; + if (numberFieldOperators.includes(subOperator as NumberFieldOperator)) { + subOperatorSymbol = getOperatorLabel(subOperator as Operator, field.subfield); + operand = subVal; + } + } + } + } else if (operator === '$all' && '$all' in operation) { + const allValue = operation.$all; + if ('$or' in allValue && Array.isArray(allValue.$or)) { + const values = allValue.$or; + operand = values.map(value => { + if (typeof value === 'object' && value !== null && '$eq' in value) { + subOperatorSymbol = getOperatorLabel('$in', field.subfield); + return (value as { $eq: unknown }).$eq; + } + return value; + }); + } else if ('$and' in allValue && Array.isArray(allValue.$and)) { + const values = allValue.$and; + operand = values.map(value => { + if (typeof value === 'object' && value !== null && '$ne' in value) { + subOperatorSymbol = getOperatorLabel('$nin', field.subfield); + return (value as { $ne: unknown }).$ne; + } + return value; + }); + } else { + const subKeys = Object.keys(allValue); + if (subKeys.length > 0) { + // biome-ignore lint/style/noNonNullAssertion: + const subOperator = subKeys[0]!; + const subVal = (allValue as Record)[subOperator]; + if (subOperator && numberFieldOperators.includes(subOperator as NumberFieldOperator)) { + subOperatorSymbol = getOperatorLabel(subOperator as Operator, field.subfield); + operand = subVal; + } + } + } + } else { + operand = Object.values(fieldQuery.operation)[0]; + } + + return { + fieldName: fieldQuery.fieldName, + operator, + operatorSymbol, + operand, + subOperatorSymbol, + }; +}; + +const decodeNumberFieldQuery = (fieldQuery: FieldQuery) => { + const operation = fieldQuery.operation as NumberFieldQueryOperation; + const operator = Object.keys(operation)[0] as NumberFieldOperator; + const operatorSymbol = numberOperatorsToSymbolMap[operator]; + + const operand = Object.values(fieldQuery.operation)[0]; + + return { + fieldName: fieldQuery.fieldName, + operator, + operatorSymbol, + operand, + }; +}; + +const decodeDateTimeFieldQuery = (fieldQuery: FieldQuery) => { + const operation = fieldQuery.operation as DateTimeFieldQueryOperation; + const operator = Object.keys(operation)[0] as DateTimeFieldOperator; + const operatorSymbol = dateTimeFieldOperatorsToSymbolMap[operator]; + + const operand = Object.values(fieldQuery.operation)[0]; + + return { + fieldName: fieldQuery.fieldName, + operator, + operatorSymbol, + operand, + }; +}; + +export const decodeFieldQuery = (fieldQuery: FieldQuery, fields: Fields): { + fieldName: FieldName, + operator: Operator, + operatorSymbol: string, + operand: unknown, + subOperatorSymbol?: string, +} => { + const field = fieldQuery.fieldName ? fields[fieldQuery.fieldName] : null; + const fieldType = field ? field.type : null; + + if (fieldType === 'enum') { + return decodeEnumFieldQuery(fieldQuery); + } + if (field && field.type === 'array') { + return decodeArrayFieldQuery(fieldQuery, field); + } + if (fieldType === 'number') { + return decodeNumberFieldQuery(fieldQuery); + } + if (fieldType === 'datetime') { + return decodeDateTimeFieldQuery(fieldQuery); + } + + const operator = Object.keys(fieldQuery.operation)[0] as Operator; + const operatorSymbol = ':'; + const operationValue = Object.values(fieldQuery.operation)[0]; + let operand: unknown = operationValue; + + if (operator === '$text') { + if (typeof operationValue === 'object' && operationValue !== null && '$search' in operationValue) { + operand = (operationValue as { $search: string }).$search; + } else { + operand = ''; + } + } + + return { + fieldName: fieldQuery.fieldName, + operator, + operatorSymbol, + operand, + }; +}; diff --git a/src/components/tables/SearchInput/SearchInput.module.scss b/src/components/tables/SearchInput/SearchInput.module.scss new file mode 100644 index 00000000..a148f3cf --- /dev/null +++ b/src/components/tables/SearchInput/SearchInput.module.scss @@ -0,0 +1,23 @@ +/* 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; + +@layer baklava.components { + .bk-search { + @include bk.component-base(bk-search); + display: flex; + + .bk-search__input { + flex: 1; + padding-left: bk.$spacing-7; + } + + .bk-search__icon { + position: absolute; + left: 0; + bottom: bk.$spacing-1; + } + } +} diff --git a/src/components/tables/SearchInput/SearchInput.stories.tsx b/src/components/tables/SearchInput/SearchInput.stories.tsx new file mode 100644 index 00000000..7fc67395 --- /dev/null +++ b/src/components/tables/SearchInput/SearchInput.stories.tsx @@ -0,0 +1,36 @@ +/* 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 { SearchInput } from './SearchInput.tsx'; +import type { Meta, StoryObj } from '@storybook/react'; + + +type SearchInputArgs = React.ComponentProps; +type Story = StoryObj; + +const SearchInputTemplate = (props: SearchInputArgs) => { + const [value, setValue] = React.useState(''); + + return ( + setValue(evt.target.value)} /> + ); +}; + +export default { + component: SearchInput, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: {}, + args: { + }, + render: (args: SearchInputArgs) => ( + + ), +} satisfies Meta; + + +export const Standard: Story = {}; diff --git a/src/components/tables/SearchInput/SearchInput.tsx b/src/components/tables/SearchInput/SearchInput.tsx new file mode 100644 index 00000000..17a84d90 --- /dev/null +++ b/src/components/tables/SearchInput/SearchInput.tsx @@ -0,0 +1,28 @@ + +/* 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 { Icon } from '../../graphics/Icon/Icon.tsx'; +import { Input } from '../../forms/controls/Input/Input.tsx'; + +import cl from './SearchInput.module.scss'; + + +export type SearchInputProps = React.ComponentPropsWithoutRef; +export const SearchInput = (props: SearchInputProps) => { + return ( +
+ + +
+ ); +}; +SearchInput.displayName = 'SearchInput'; diff --git a/src/components/tables/util/async_util.ts b/src/components/tables/util/async_util.ts new file mode 100644 index 00000000..c8e9174c --- /dev/null +++ b/src/components/tables/util/async_util.ts @@ -0,0 +1,13 @@ +/* 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/. */ + +// Wait for the given delay (in ms) +export const delay = async (delayMs: number) => { + if (typeof window === 'object') { + return new Promise(resolve => { window.setTimeout(resolve, delayMs); }); + } if (typeof global === 'object') { + return new Promise(resolve => { global.setTimeout(resolve, delayMs); }); + } + throw new Error('Unknown environment'); +}; diff --git a/src/components/tables/util/generateData.ts b/src/components/tables/util/generateData.ts new file mode 100644 index 00000000..140000df --- /dev/null +++ b/src/components/tables/util/generateData.ts @@ -0,0 +1,43 @@ +/* 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 { seed, randFirstName, randLastName, randEmail, randCompanyName, randBetweenDate, randUuid, randJobDescriptor } from '@ngneat/falso'; + +export type User = { + id: string, + name: string, + email: string, + company: string, + joinDate: Date, + dummy_1: string, + dummy_2: string, + dummy_3: string, + dummy_4: string, + dummy_5: string, +}; + +export const generateData = ({ numItems = 10 } = {}) => { + seed('some-constant-seed'); // Use a fixed seed for consistent results + + const data: Array = []; + + for (let i = 0; i < numItems; i += 1) { + const firstName = randFirstName(); + const lastName = randLastName(); + data.push({ + id: randUuid(), + name: `${firstName} ${lastName}`, + email: randEmail({ firstName, lastName }), + company: randCompanyName(), + joinDate: randBetweenDate({ from: new Date('01/01/2020'), to: new Date() }), + dummy_1: `${firstName} ${lastName}`, + dummy_2: randCompanyName(), + dummy_3: randCompanyName(), + dummy_4: randEmail({ firstName, lastName }), + dummy_5: randJobDescriptor(), + }); + } + + return data; +}; diff --git a/src/components/tables/util/sorting_util.ts b/src/components/tables/util/sorting_util.ts new file mode 100644 index 00000000..8849e9d1 --- /dev/null +++ b/src/components/tables/util/sorting_util.ts @@ -0,0 +1,28 @@ +/* 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/. */ + +const compareOrdered = (a: number, b: number): number => { + return a === b ? 0 : a > b ? 1 : -1; +}; + +// Sort by JS `Date` +// Note: builtin react-table `datetime` sort method does not handle dates that may be undefined/null: +// https://github.com/tannerlinsley/react-table/blob/master/src/sortTypes.js +// biome-ignore lint/suspicious/noExplicitAny: +export const sortDateTime = (row1: { values: any }, row2: { values: any }, columnId: string) => { + const cell1 = row1.values[columnId]; + const cell2 = row2.values[columnId]; + + if (!(cell1 instanceof Date) && !(cell2 instanceof Date)) { + return 0; + } + if (!(cell1 instanceof Date)) { + return 1; // Consider a nonexisting date to come *after* an existing date + } + if (!(cell2 instanceof Date)) { + return -1; // Consider a nonexisting date to come *after* an existing date + } + + return compareOrdered(cell1.getTime(), cell2.getTime()); +}; diff --git a/src/components/text/Tag/Tag.tsx b/src/components/text/Tag/Tag.tsx index 4b1c77b3..43715709 100644 --- a/src/components/text/Tag/Tag.tsx +++ b/src/components/text/Tag/Tag.tsx @@ -13,7 +13,7 @@ import cl from './Tag.module.scss'; export { cl as TagClassNames }; -export type TagProps = ComponentProps<'div'> & { +export type TagProps = Omit, 'content' | 'children'> & { /** Whether this component should be unstyled. */ unstyled?: undefined | boolean, @@ -23,7 +23,6 @@ export type TagProps = ComponentProps<'div'> & { /** Callback to remove the tag. If set, display a close icon, otherwise it is hidden. */ onRemove?: () => void, }; - /** * A tag component. */ diff --git a/src/styling/defs.scss b/src/styling/defs.scss index a75a07e2..c959b621 100644 --- a/src/styling/defs.scss +++ b/src/styling/defs.scss @@ -7,6 +7,7 @@ @forward './variables.scss'; @forward './global/fonts.scss' hide styles; @forward './global/accessibility.scss' hide styles; +@forward './global/shimmer.scss' hide styles; @forward './context/theming.scss' hide styles; @use './variables.scss' as vars; diff --git a/src/styling/global/shimmer.scss b/src/styling/global/shimmer.scss new file mode 100644 index 00000000..8eb68f00 --- /dev/null +++ b/src/styling/global/shimmer.scss @@ -0,0 +1,27 @@ +/* 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/. */ + +@mixin shimmer($base-color, $highlight-color) { + background-color: $base-color; + + background-image: linear-gradient( + 90deg, + $base-color 0%, + $highlight-color 30%, + $base-color 60% + ); + background-size: 200% 100%; + + @keyframes shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } + } + + animation: shimmer 3s ease-in-out; + animation-iteration-count: infinite; +} diff --git a/src/types/react-table.d.ts b/src/types/react-table.d.ts new file mode 100644 index 00000000..55cec697 --- /dev/null +++ b/src/types/react-table.d.ts @@ -0,0 +1,158 @@ +/* eslint-disable @typescript-eslint/indent */ + +// See: +// https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/react-table + +import type { + UseColumnOrderInstanceProps, + UseColumnOrderState, + UseExpandedHooks, + UseExpandedInstanceProps, + UseExpandedOptions, + UseExpandedRowProps, + UseExpandedState, + UseFiltersColumnOptions, + UseFiltersColumnProps, + UseFiltersInstanceProps, + UseFiltersOptions, + UseFiltersState, + UseGlobalFiltersColumnOptions, + UseGlobalFiltersInstanceProps, + UseGlobalFiltersOptions, + UseGlobalFiltersState, + UseGroupByCellProps, + UseGroupByColumnOptions, + UseGroupByColumnProps, + UseGroupByHooks, + UseGroupByInstanceProps, + UseGroupByOptions, + UseGroupByRowProps, + UseGroupByState, + UsePaginationInstanceProps, + UsePaginationOptions, + UsePaginationState, + UseResizeColumnsColumnOptions, + UseResizeColumnsColumnProps, + UseResizeColumnsOptions, + UseResizeColumnsState, + UseRowSelectHooks, + UseRowSelectInstanceProps, + UseRowSelectOptions, + UseRowSelectRowProps, + UseRowSelectState, + UseRowStateCellProps, + UseRowStateInstanceProps, + UseRowStateOptions, + UseRowStateRowProps, + UseRowStateState, + UseSortByColumnOptions, + UseSortByColumnProps, + UseSortByHooks, + UseSortByInstanceProps, + UseSortByOptions, + UseSortByState, +} from 'react-table'; + + import type * as FQ from '../components/tables/MultiSearch/filterQuery.ts'; + + +interface CustomColumnProps { + configurable?: boolean, + primary?: boolean, + label?: string, +} + +interface UseCustomFiltersState { + customFilters: FQ.FilterQuery; +} + +interface UseTableStreamState { + endOfStream: boolean, + partialStream: boolean, +} + +interface UseCustomFiltersInstanceProps { + setCustomFilters: (filters: FQ.FilterQuery) => void; +} + +declare module 'react-table' { + export interface TableOptions + extends UseExpandedOptions, + UseFiltersOptions, + UseGlobalFiltersOptions, + UseGroupByOptions, + UsePaginationOptions, + UseResizeColumnsOptions, + UseRowSelectOptions, + UseRowStateOptions, + UseSortByOptions + // Note that having Record here allows you to add anything to the options, this matches the spirit of the + // underlying js library, but might be cleaner if it's replaced by a more specific type that matches your + // feature set, this is a safe default. + //Record + {} + + export interface Hooks + extends UseExpandedHooks, + UseGroupByHooks, + UseRowSelectHooks, + UseSortByHooks {} + + export interface TableInstance + extends UseColumnOrderInstanceProps, + UseExpandedInstanceProps, + UseFiltersInstanceProps, + UseGlobalFiltersInstanceProps, + UseGroupByInstanceProps, + UsePaginationInstanceProps, + UseRowSelectInstanceProps, + UseRowStateInstanceProps, + UseSortByInstanceProps, + UseCustomFiltersInstanceProps {} + + export interface TableState + extends UseColumnOrderState, + UseExpandedState, + UseFiltersState, + UseGlobalFiltersState, + UseGroupByState, + UsePaginationState, + UseResizeColumnsState, + UseRowSelectState, + UseRowStateState, + UseSortByState, + UseCustomFiltersState, + UseTableStreamState {} + + + interface BaklavaCustomColumnInterface { + className?: string, + style?: CSSProperties | undefined, + } + export interface ColumnInterface + extends UseFiltersColumnOptions, + UseGlobalFiltersColumnOptions, + UseGroupByColumnOptions, + UseResizeColumnsColumnOptions, + UseSortByColumnOptions, + CustomColumnProps, + BaklavaCustomColumnInterface + {} + + export interface ColumnInstance + extends UseFiltersColumnProps, + UseGroupByColumnProps, + UseResizeColumnsColumnProps, + UseSortByColumnProps, + CustomColumnProps {} + + export interface Cell + extends UseGroupByCellProps, + UseRowStateCellProps {} + + export interface Row + extends UseExpandedRowProps, + UseGroupByRowProps, + UseRowSelectRowProps, + UseRowStateRowProps {} +} diff --git a/src/util/componentUtil.ts b/src/util/componentUtil.ts index 333d2694..e58ec63f 100644 --- a/src/util/componentUtil.ts +++ b/src/util/componentUtil.ts @@ -5,7 +5,7 @@ // Note: use the `dedupe` variant so that the consumer of a component can overwrite classes from the component using // `` import classNames from 'classnames/dedupe'; -import { type Argument as ClassNameArgument } from 'classnames'; +import type { Argument as ClassNameArgument } from 'classnames'; export { classNames, type ClassNameArgument }; diff --git a/src/util/hooks/useFocus.ts b/src/util/hooks/useFocus.ts new file mode 100644 index 00000000..e6164cdf --- /dev/null +++ b/src/util/hooks/useFocus.ts @@ -0,0 +1,48 @@ +/* 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'; + + +export type UseFocusProps = { + autoFocus?: undefined | boolean, + onFocus?: undefined | ((event: React.FocusEvent) => void), + onBlur?: undefined | ((event: React.FocusEvent) => void), +}; +export const useFocus = ({ autoFocus = false, onFocus, onBlur }: UseFocusProps) => { + const ref = React.useRef(null); + + const [isFocused, setIsFocused] = React.useState(autoFocus); + + React.useEffect(() => { + if (ref.current) { + if (isFocused) { + ref.current.focus(); + } else { + ref.current.blur(); + } + } + }, [isFocused]); + + const handleFocus = (event: React.FocusEvent) => { + setIsFocused(true); + if (onFocus) { + onFocus(event); + } + }; + + const handleBlur = (event: React.FocusEvent) => { + setIsFocused(false); + if (onBlur) { + onBlur(event); + } + }; + + return { + ref, + isFocused, + handleFocus, + handleBlur, + }; +}; diff --git a/src/util/hooks/useOutsideClickHandler.ts b/src/util/hooks/useOutsideClickHandler.ts new file mode 100644 index 00000000..8a5f9d88 --- /dev/null +++ b/src/util/hooks/useOutsideClickHandler.ts @@ -0,0 +1,36 @@ +/* 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'; + + +/** + * Hook that handles clicks outside of the passed ref. + */ +export const useOutsideClickHandler = ( + ref: React.RefObject | Array>, + onOutsideClick: (event?: MouseEvent) => void, +) => { + const hasClickedOutside = React.useCallback((ref: React.RefObject, event: MouseEvent) => { + const target: null | EventTarget = event.target; + return ref.current && target instanceof Node && !ref.current.contains(target); + }, []); + + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ((Array.isArray(ref) && ref.every(r => hasClickedOutside(r, event))) + || (!Array.isArray(ref) && hasClickedOutside(ref, event))) { + onOutsideClick(); + } + }; + + // Bind the event listener + document.addEventListener('mousedown', handleClickOutside); + + return () => { + // Unbind the event listener on clean up + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [ref, hasClickedOutside, onOutsideClick]); +}; diff --git a/src/util/objectUtil.test.ts b/src/util/objectUtil.test.ts new file mode 100644 index 00000000..07a8cab8 --- /dev/null +++ b/src/util/objectUtil.test.ts @@ -0,0 +1,307 @@ +/* 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 { describe, test, expect } from 'vitest'; + +import * as ObjectUtil from './objectUtil.ts'; + + +describe('ObjectUtil', () => { + test('isObject should resolve true given an object', () => { + expect(ObjectUtil.isObject(undefined)).toBe(false); + expect(ObjectUtil.isObject(null)).toBe(false); + expect(ObjectUtil.isObject(Number.NaN)).toBe(false); + expect(ObjectUtil.isObject(0)).toBe(false); + expect(ObjectUtil.isObject(-42)).toBe(false); + expect(ObjectUtil.isObject('')).toBe(false); + expect(ObjectUtil.isObject('foo')).toBe(false); + expect(ObjectUtil.isObject(42n)).toBe(false); + + expect(ObjectUtil.isObject(Object.create(null))).toBe(true); + expect(ObjectUtil.isObject({})).toBe(true); + expect(ObjectUtil.isObject({ x: 42 })).toBe(true); + expect(ObjectUtil.isObject([])).toBe(true); + expect(ObjectUtil.isObject(new String('foo'))).toBe(true); + expect(ObjectUtil.isObject(new Number(42))).toBe(true); + expect(ObjectUtil.isObject(/regex/)).toBe(true); + expect(ObjectUtil.isObject((x: number) => x + 1)).toBe(true); + expect(ObjectUtil.isObject(class Foo {})).toBe(true); + expect(ObjectUtil.isObject(new class Foo {}())).toBe(true); + }); + + test('isObjectDict should resolve true given an object', () => { + expect(ObjectUtil.isObjectDict(undefined)).toBe(false); + expect(ObjectUtil.isObjectDict(null)).toBe(false); + expect(ObjectUtil.isObjectDict(Number.NaN)).toBe(false); + expect(ObjectUtil.isObjectDict(0)).toBe(false); + expect(ObjectUtil.isObjectDict(-42)).toBe(false); + expect(ObjectUtil.isObjectDict('')).toBe(false); + expect(ObjectUtil.isObjectDict('foo')).toBe(false); + expect(ObjectUtil.isObjectDict(42n)).toBe(false); + + expect(ObjectUtil.isObjectDict(Object.create(null))).toBe(true); + expect(ObjectUtil.isObjectDict({})).toBe(true); + expect(ObjectUtil.isObjectDict({ x: 42 })).toBe(true); + expect(ObjectUtil.isObjectDict([])).toBe(true); + expect(ObjectUtil.isObjectDict(new String('foo'))).toBe(true); + expect(ObjectUtil.isObjectDict(new Number(42))).toBe(true); + expect(ObjectUtil.isObjectDict(/regex/)).toBe(true); + expect(ObjectUtil.isObjectDict((x: number) => x + 1)).toBe(true); + expect(ObjectUtil.isObjectDict(class Foo {})).toBe(true); + expect(ObjectUtil.isObjectDict(new class Foo {}())).toBe(true); + }); + + test('isPlainObject should resolve true given a plain object (prototype: null or Object.prototype)', () => { + expect(ObjectUtil.isPlainObject(undefined)).toBe(false); + expect(ObjectUtil.isPlainObject(null)).toBe(false); + expect(ObjectUtil.isPlainObject(Number.NaN)).toBe(false); + expect(ObjectUtil.isPlainObject(0)).toBe(false); + expect(ObjectUtil.isPlainObject(-42)).toBe(false); + expect(ObjectUtil.isPlainObject('')).toBe(false); + expect(ObjectUtil.isPlainObject('foo')).toBe(false); + expect(ObjectUtil.isPlainObject(42n)).toBe(false); + + expect(ObjectUtil.isPlainObject([])).toBe(false); + expect(ObjectUtil.isPlainObject(new String('foo'))).toBe(false); + expect(ObjectUtil.isPlainObject(new Number(42))).toBe(false); + expect(ObjectUtil.isPlainObject(/regex/)).toBe(false); + expect(ObjectUtil.isPlainObject((x: number) => x + 1)).toBe(false); + expect(ObjectUtil.isPlainObject(class Foo {})).toBe(false); + expect(ObjectUtil.isPlainObject(new class Foo {}())).toBe(false); + + expect(ObjectUtil.isPlainObject(Object.create(null))).toBe(true); + expect(ObjectUtil.isPlainObject({})).toBe(true); + expect(ObjectUtil.isPlainObject({ x: 42 })).toBe(true); + }); + + describe('map', () => { + test('should map the given function over the object properties', () => { + const obj: ObjectUtil.Dict = { + a: '', + b: 'hello', + c: 'foo', + }; + + // Note: TS should be able to infer `ObjectUtil.Dict` for `objMapped`. However this is hard to + // test automatically because we cannot assert anything against `any`. Can manually check for it instead. + const objMapped: ObjectUtil.Dict = ObjectUtil.map(obj, x => x.length); + + expect(objMapped).toStrictEqual({ + a: 0, + b: 5, + c: 3, + }); + }); + + test('should be immutable', () => { + const obj: ObjectUtil.Dict = { + a: 42, + b: -1, + c: 0, + }; + + const objMapped: ObjectUtil.Dict = ObjectUtil.map(obj, x => x + 1); + + expect(objMapped).toStrictEqual({ + a: 43, + b: 0, + c: 1, + }); + + // Original reference should be unchanged + expect(objMapped).not.toBe(obj); + expect(obj).toStrictEqual({ + a: 42, + b: -1, + c: 0, + }); + }); + + test('should pass key as optional second argument', () => { + const obj = { + a: '', + b: 'hello', + c: 'foo', + } as const; + + const objMapped: ObjectUtil.Dict = ObjectUtil.map(obj, (_x, key) => key); + + expect(objMapped).toStrictEqual({ + a: 'a', + b: 'b', + c: 'c', + }); + }); + }); + + describe('filter', () => { + test('should filter just the properties that satisfy the given predicate function', () => { + const obj = { + a: 42, + b: -1, + c: 0, + } as const; + + const objFiltered: ObjectUtil.Dict = ObjectUtil.filter(obj, x => x >= 0); + + expect(objFiltered).toStrictEqual({ + a: 42, + c: 0, + }); + }); + + test('should be immutable', () => { + const obj = { + a: 42, + b: -1, + c: 0, + } as const; + + const objFiltered: ObjectUtil.Dict = ObjectUtil.filter(obj, x => x >= 0); + + expect(objFiltered).toStrictEqual({ + a: 42, + c: 0, + }); + + // Original reference should be unchanged + expect(objFiltered).not.toBe(obj); + expect(obj).toStrictEqual({ + a: 42, + b: -1, + c: 0, + }); + }); + + test('should pass key as optional second argument', () => { + const obj = { + a: '', + b: 'hello', + c: 'foo', + } as const; + + const objFiltered: ObjectUtil.Dict> = + ObjectUtil.filter(obj, (_x, key) => key !== 'a'); + + expect(objFiltered).toStrictEqual({ + b: 'hello', + c: 'foo', + }); + }); + }); + + describe('filterWithTypeGuard', () => { + test('should filter just the properties that satisfy the given predicate function', () => { + const obj = { + a: 42, + b: -1, + c: 0, + } as const; + + const objFiltered: ObjectUtil.Dict = + ObjectUtil.filterWithTypeGuard(obj, (x: number): x is 0 | 42 => x >= 0); + + expect(objFiltered).toStrictEqual({ + a: 42, + c: 0, + }); + }); + + test('should be immutable', () => { + const obj = { + a: 42, + b: -1, + c: 0, + } as const; + + const objFiltered: ObjectUtil.Dict = ObjectUtil.filterWithTypeGuard( + obj, + (x): x is 0 | 42 => x >= 0, + ); + + expect(objFiltered).toStrictEqual({ + a: 42, + c: 0, + }); + + // Original reference should be unchanged + expect(objFiltered).not.toBe(obj); + expect(obj).toStrictEqual({ + a: 42, + b: -1, + c: 0, + }); + }); + + test('should pass key as optional second argument', () => { + const obj = { + a: '', + b: 'hello', + c: 'foo', + } as const; + + const objFiltered: ObjectUtil.Dict> = + ObjectUtil.filterWithTypeGuard(obj, (x, key): x is 'hello' | 'foo' => key !== 'a'); + + expect(objFiltered).toStrictEqual({ + b: 'hello', + c: 'foo', + }); + }); + }); + + describe('reduce', () => { + test('should reduce the properties of the object to a single value', () => { + const obj = { + a: 10, + b: 20, + c: 30, + } as const; + + const sum: number = ObjectUtil.reduce(obj, (acc, [key, value]) => acc + value, 0); + + expect(sum).toBe(60); + }); + + test('should be immutable', () => { + const obj = { + a: 10, + b: 20, + c: 30, + } as const; + + const sum: number = ObjectUtil.reduce(obj, (acc, [key, value]) => acc + value, 0); + + expect(sum).toBe(60); + + // Original reference should be unchanged + expect(obj).toStrictEqual({ + a: 10, + b: 20, + c: 30, + }); + }); + }); + + describe.skip('sort', () => {}); // TODO + describe.skip('getSingleKey', () => {}); // TODO + + describe('keyBy', () => { + test('should create an object from an array, keyed by the given deriveKey callback', () => { + const persons = [ + { id: 'a', name: 'Alice' }, + { id: 'b', name: 'Bob' }, + { id: 'c', name: 'Charlie' }, + ]; + + const personsById = ObjectUtil.keyBy(persons, person => person.id); + + expect(personsById).toStrictEqual({ + a: { id: 'a', name: 'Alice' }, + b: { id: 'b', name: 'Bob' }, + c: { id: 'c', name: 'Charlie' }, + }); + }); + }); +}); diff --git a/src/util/objectUtil.ts b/src/util/objectUtil.ts new file mode 100644 index 00000000..374859a6 --- /dev/null +++ b/src/util/objectUtil.ts @@ -0,0 +1,149 @@ +/* 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 $msg from 'message-tag'; + + +// Note: this also exists as a primitive in TS under the name `Record`, but the term "record" is better served for +// objects with a finite set of known keys with individually-typed properties (e.g. `{ x: number, y: string }`). +// Prefer "dict" (dictionary) for objects that serve as a map over an arbitrary set of keys. +export type Dict = { [P in K]: T }; + +export type ValueOf = O[keyof O]; +export type EntryOf = [key: keyof O, value: ValueOf]; + + +// Check if the given value is an object (treats the object as a *closed* type, i.e. `object`, cannot be used as dict) +export const isObject = (obj: unknown): obj is object => { + // Note: functions are objects in JS, despite their difference in `typeof`. To exclude functions, perform an explicit + // typeof check in addition to `isObject()`. + return (typeof obj === 'object' || typeof obj === 'function') && obj !== null; +}; + +// Check if the given value is an object (treats the object as an *open* type, i.e. `Dict`, can be used as dictionary) +export const isObjectDict = (obj: unknown): obj is Dict => { + return isObject(obj); // No difference at runtime +}; + +// Check if the given object is a plain object (prototype should be either `null` or `Object.prototype`) +export const isPlainObject = (obj: unknown): obj is object => { + if (!isObject(obj)) { return false; } + + const proto = Object.getPrototypeOf(obj); + return proto === null || proto === Object.prototype; +}; + + +// Versions of `Object.{entries,fromEntries,keys,values}` that maintain type information of keys/values +export const entries = (obj: O): Array> => + // biome-ignore lint/suspicious/noExplicitAny: purposefully narrowed type + Object.entries(obj) as any; +export const fromEntries = (entries: Array>): O => + // biome-ignore lint/suspicious/noExplicitAny: purposefully narrowed type + Object.fromEntries(entries) as any; +export const keys = (obj: O): Array => + // biome-ignore lint/suspicious/noExplicitAny: purposefully narrowed type + Object.keys(obj) as any; +export const values = (obj: O): Array> => + // biome-ignore lint/suspicious/noExplicitAny: purposefully narrowed type + Object.values(obj) as any; + + +// Note: in TypeScript it is not currently supported to use `in` or `hasOwnProperty` as a type guard on generic objects. +// See: https://github.com/microsoft/TypeScript/issues/21732 +// XXX This should only be used if `K` is a literal type, if `K` is something like `string` this breaks the type +export const hasProp = ( + obj: O, + propKey: K, +): obj is O & { [key in K]: unknown } => + propKey in obj; + +// Same as `hasProp`, but specifically checks for an own property (for TS there is no difference). +// XXX This should only be used if `K` is a literal type, if `K` is something like `string` this breaks the type +export const hasOwnProp = ( + obj: O, + propKey: K, +): obj is O & { [key in K]: unknown } => + Object.prototype.hasOwnProperty.call(obj, propKey); + + +// Map over the values of the given object +export const map = ( + obj: O, + // Note: `Result` generic is used to be able to infer the return value of the callback return type + fn: (value: ValueOf, key: keyof O) => Result, +): Dict => { + const result = {} as Dict; + for (const key of keys(obj)) { + result[key] = fn(obj[key], key); + } + return result; +}; + +export const filter = >( + obj: O, + predicate: (value: ValueOf, key: keyof O) => boolean, +): Dict => { + const entriesFiltered: Array<[keyof O, P]> = entries(obj) + .filter((entry: [keyof O, ValueOf]): entry is [keyof O, P] => { + const [key, value] = entry; + return predicate(value, key); + }); + return fromEntries>(entriesFiltered); +}; + +// Variant of `filter` where the predicate is a type guard +export const filterWithTypeGuard = >( + obj: O, + predicate: (value: ValueOf, key: keyof O) => value is P, +): Dict => { + const entriesFiltered: Array<[keyof O, P]> = entries(obj) + .filter((entry: [keyof O, ValueOf]): entry is [keyof O, P] => { + const [key, value] = entry; + return predicate(value, key); + }); + return fromEntries>(entriesFiltered); +}; + +export const reduce = ( + obj: O, + fn: (acc: A, entry: [key: keyof O, value: ValueOf]) => A, + initial: A, +) => { + return entries(obj).reduce(fn, initial); +}; + + +export const sort = ( + obj: O, + compare: ([key1, value1]: EntryOf, [key2, value2]: EntryOf) => number, +) => { + return fromEntries( + entries(obj).sort(compare), + ); +}; + + +export const getSingleKey = (obj: O): keyof O => { + const objKeys = keys(obj); + const singleKey: undefined | keyof O = objKeys[0]; + + if (objKeys.length !== 1 || typeof singleKey === 'undefined') { + throw new TypeError($msg`Expected object with a single key, given ${obj}`); + } + + return singleKey; +}; + + +export const keyBy = (items: ReadonlyArray, deriveKey: (item: T) => K): Dict => { + return items.reduce( + (itemsKeyed, item) => { + const key = deriveKey(item); + itemsKeyed[key] = item; + return itemsKeyed; + }, + {} as Dict, + ); +}; diff --git a/src/util/random.ts b/src/util/random.ts new file mode 100644 index 00000000..24c4afdc --- /dev/null +++ b/src/util/random.ts @@ -0,0 +1,9 @@ +/* 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/. */ + +// https://stackoverflow.com/a/44622300/233884 +export type GenerateRandomIdOptions = { length: number, prefix: string }; +export const generateRandomId = ({ length = 12, prefix = '' }: Partial = {}): string => { + return prefix + Array.from(Array(length), () => Math.floor(Math.random() * 36).toString(36)).join(''); +}; diff --git a/src/util/reactUtil.ts b/src/util/reactUtil.ts index 173c543f..f4377948 100644 --- a/src/util/reactUtil.ts +++ b/src/util/reactUtil.ts @@ -58,3 +58,18 @@ export const useEffectOnce = (fn: () => void) => { } }, []); } + +export const usePrevious = (value: T) => { + const ref: React.MutableRefObject = React.useRef(null); + React.useEffect(() => { + ref.current = value; + }); + return ref.current; +}; + +export const useEffectAsync = (effect: () => Promise, inputs?: undefined | React.DependencyList): void => { + // biome-ignore lint/correctness/useExhaustiveDependencies: + React.useEffect(() => { + effect(); + }, inputs); +};