From 47af732fced9d510de36a49f84e3f44ad94a3324 Mon Sep 17 00:00:00 2001 From: mkrause Date: Wed, 20 Nov 2024 13:52:07 +0100 Subject: [PATCH 01/32] Port over DataTable code from earlier Baklava. --- package-lock.json | 23 + package.json | 4 +- package.json.js | 3 + src/assets/icons/_icons.ts | 7 + .../tables/DataTable/DataTable.stories.tsx | 98 + .../tables/DataTable/DataTableContext.tsx | 31 + .../tables/DataTable/DataTableEager.scss | 8 + .../tables/DataTable/DataTableEager.tsx | 174 ++ .../tables/DataTable/DataTableLazy.scss | 38 + .../tables/DataTable/DataTableLazy.tsx | 292 +++ .../tables/DataTable/DataTableStream.scss | 8 + .../tables/DataTable/DataTableStream.tsx | 531 ++++ .../tables/DataTable/filtering/Filtering.ts | 223 ++ .../DataTable/pagination/Pagination.scss | 76 + .../DataTable/pagination/Pagination.tsx | 128 + .../pagination/PaginationSizeSelector.scss | 36 + .../pagination/PaginationSizeSelector.tsx | 53 + .../pagination/PaginationStream.scss | 56 + .../DataTable/pagination/PaginationStream.tsx | 79 + .../DataTable/plugins/useCustomFilters.tsx | 46 + .../DataTable/plugins/useRowSelectColumn.scss | 15 + .../DataTable/plugins/useRowSelectColumn.tsx | 35 + .../tables/DataTable/table/DataTable.scss | 180 ++ .../tables/DataTable/table/DataTable.tsx | 244 ++ .../DataTable/table/DataTablePlaceholder.scss | 96 + .../DataTable/table/DataTablePlaceholder.tsx | 188 ++ .../tables/MultiSearch/MultiSearch.scss | 149 ++ .../MultiSearch/MultiSearch.stories.tsx | 238 ++ .../tables/MultiSearch/MultiSearch.tsx | 2285 +++++++++++++++++ src/components/tables/util/generateData.ts | 22 + src/types/react-table.d.ts | 159 ++ 31 files changed, 5524 insertions(+), 1 deletion(-) create mode 100644 src/components/tables/DataTable/DataTable.stories.tsx create mode 100644 src/components/tables/DataTable/DataTableContext.tsx create mode 100644 src/components/tables/DataTable/DataTableEager.scss create mode 100644 src/components/tables/DataTable/DataTableEager.tsx create mode 100644 src/components/tables/DataTable/DataTableLazy.scss create mode 100644 src/components/tables/DataTable/DataTableLazy.tsx create mode 100644 src/components/tables/DataTable/DataTableStream.scss create mode 100644 src/components/tables/DataTable/DataTableStream.tsx create mode 100644 src/components/tables/DataTable/filtering/Filtering.ts create mode 100644 src/components/tables/DataTable/pagination/Pagination.scss create mode 100644 src/components/tables/DataTable/pagination/Pagination.tsx create mode 100644 src/components/tables/DataTable/pagination/PaginationSizeSelector.scss create mode 100644 src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx create mode 100644 src/components/tables/DataTable/pagination/PaginationStream.scss create mode 100644 src/components/tables/DataTable/pagination/PaginationStream.tsx create mode 100644 src/components/tables/DataTable/plugins/useCustomFilters.tsx create mode 100644 src/components/tables/DataTable/plugins/useRowSelectColumn.scss create mode 100644 src/components/tables/DataTable/plugins/useRowSelectColumn.tsx create mode 100644 src/components/tables/DataTable/table/DataTable.scss create mode 100644 src/components/tables/DataTable/table/DataTable.tsx create mode 100644 src/components/tables/DataTable/table/DataTablePlaceholder.scss create mode 100644 src/components/tables/DataTable/table/DataTablePlaceholder.tsx create mode 100644 src/components/tables/MultiSearch/MultiSearch.scss create mode 100644 src/components/tables/MultiSearch/MultiSearch.stories.tsx create mode 100644 src/components/tables/MultiSearch/MultiSearch.tsx create mode 100644 src/components/tables/util/generateData.ts create mode 100644 src/types/react-table.d.ts diff --git a/package-lock.json b/package-lock.json index 864692e8..77f31bc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "react-dom": "^19.0.0-rc.1", "react-error-boundary": "^4.1.2", "react-hook-form": "^7.53.2", + "react-table": "^7.8.0", "react-toastify": "^10.0.6" }, "devDependencies": { @@ -39,6 +40,7 @@ "@types/node": "^22.9.0", "@types/react": "npm:types-react@rc", "@types/react-dom": "npm:types-react-dom@rc", + "@types/react-table": "^7.7.20", "@vitejs/plugin-react": "^4.3.3", "@vitest/ui": "^2.1.5", "glob": "^11.0.0", @@ -3766,6 +3768,15 @@ "@types/react": "*" } }, + "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", @@ -10802,6 +10813,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", diff --git a/package.json b/package.json index 3b430abf..ac856c1c 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,8 @@ "postcss-color-contrast": "^1.1.0", "lightningcss": "^1.28.1", "@types/react": "npm:types-react@rc", - "@types/react-dom": "npm:types-react-dom@rc" + "@types/react-dom": "npm:types-react-dom@rc", + "@types/react-table": "^7.7.20" }, "dependencies": { "date-fns": "^4.1.0", @@ -100,6 +101,7 @@ "react-error-boundary": "^4.1.2", "@floating-ui/react": "^0.26.28", "react-toastify": "^10.0.6", + "react-table": "^7.8.0", "effect": "^3.10.15", "react-hook-form": "^7.53.2", "optics-ts": "^2.4.1" diff --git a/package.json.js b/package.json.js index 68e36290..c8fddf06 100644 --- a/package.json.js +++ b/package.json.js @@ -140,6 +140,8 @@ const packageConfig = { //'@types/react-dom': '^18.2.22', '@types/react': 'npm:types-react@rc', '@types/react-dom': 'npm:types-react-dom@rc', + + '@types/react-table': '^7.7.20', }, // Dependencies needed when running the generated build @@ -156,6 +158,7 @@ const packageConfig = { '@floating-ui/react': '^0.26.28', 'react-toastify': '^10.0.6', + 'react-table': '^7.8.0', 'effect': '^3.10.15', 'react-hook-form': '^7.53.2', diff --git a/src/assets/icons/_icons.ts b/src/assets/icons/_icons.ts index 61491339..323597b4 100644 --- a/src/assets/icons/_icons.ts +++ b/src/assets/icons/_icons.ts @@ -56,3 +56,10 @@ export const icons = { 'warning': {}, 'workflows': {}, } as const satisfies Record; + +export type IconKey = keyof typeof icons; + +const iconKeys = new Set(Object.keys(icons)); +export const isIconKey = (iconKey: string): iconKey is IconKey => { + return iconKeys.has(iconKey); +}; diff --git a/src/components/tables/DataTable/DataTable.stories.tsx b/src/components/tables/DataTable/DataTable.stories.tsx new file mode 100644 index 00000000..e7c0f786 --- /dev/null +++ b/src/components/tables/DataTable/DataTable.stories.tsx @@ -0,0 +1,98 @@ +/* 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 { Meta, StoryObj } from '@storybook/react'; + +import { DataTable } from './table/DataTable.tsx'; + + +type DataTableArgs = React.ComponentProps; +type Story = StoryObj; + + + +import * as ReactTable from 'react-table'; +import { createTableContext, TableContextState } from './DataTableContext.tsx'; +const DataTableContext = ({ children }: React.PropsWithChildren) => { + type User = { name: string }; + const columns = React.useMemo>>(() => [ + { + id: 'name', + } + ], []); + const data = React.useMemo>(() => [ + { name: 'Alice' }, + ], []); + const options = React.useMemo>(() => ({ columns, data }), [columns, data]); + const table = ReactTable.useTable(options); + + const context = React.useMemo>(() => ({ + status: { ready: true, loading: false, error: null }, + setStatus() {}, + reload() {}, + table, + }), [table.state]); + // Note: the `table` reference is mutated, so cannot use it as dependency for `useMemo` directly + + const TableContext = React.useMemo(() => createTableContext(), []); + + return ( + + {children} + + ); +}; + +const DataTableTemplate = (args: DataTableArgs) => { + type User = { name: string }; + const columns = React.useMemo>>(() => [ + { + id: 'name', + getSortByToggleProps() { return {}; }, + Header: 'Name:', + accessor: (user: User) => user.name, + } + ], []); + const data = React.useMemo>(() => [ + { name: 'Alice' }, + ], []); + const options = React.useMemo>(() => ({ columns, data }), [columns, data]); + const table = ReactTable.useTable( + options, + ReactTable.useGlobalFilter, + ReactTable.useFilters, + ReactTable.useSortBy, + ReactTable.usePagination, + ReactTable.useRowSelect, + ); + + return ( +
+ Table: + +
+ ); +}; + + +export default { + component: DataTable, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: {}, + args: { + }, + render: (args) => ( + + ), +} satisfies Meta; + + +export const Standard: Story = {}; diff --git a/src/components/tables/DataTable/DataTableContext.tsx b/src/components/tables/DataTable/DataTableContext.tsx new file mode 100644 index 00000000..83a090a1 --- /dev/null +++ b/src/components/tables/DataTable/DataTableContext.tsx @@ -0,0 +1,31 @@ + +import * as React from 'react'; +import * 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.scss b/src/components/tables/DataTable/DataTableEager.scss new file mode 100644 index 00000000..e4c08872 --- /dev/null +++ b/src/components/tables/DataTable/DataTableEager.scss @@ -0,0 +1,8 @@ + +@use '../../../style/variables.scss' as bkl; +@use './DataTableLazy.scss' as dataTableLazy; + + +.bkl-data-table-eager--loading { + @include dataTableLazy.data-table-loading; +} diff --git a/src/components/tables/DataTable/DataTableEager.tsx b/src/components/tables/DataTable/DataTableEager.tsx new file mode 100644 index 00000000..096d8e72 --- /dev/null +++ b/src/components/tables/DataTable/DataTableEager.tsx @@ -0,0 +1,174 @@ + +import * as React from 'react'; +import { classNames as cx, ClassNameArgument } from '../../../util/componentUtil.ts'; +import * as ReactTable from 'react-table'; + +import { TableContextState, createTableContext, useTable } from './DataTableContext.tsx'; +import { Pagination } from './pagination/Pagination.tsx'; +import { SearchInput } from '../../../prefab/forms/SearchInput/SearchInput.tsx'; +import { MultiSearch as MultiSearchInput } from '../../../prefab/forms/MultiSearch/MultiSearch.tsx'; +import { DataTableSync } from './table/DataTable'; + +import './DataTableEager.scss'; + +export * from './DataTableContext'; +export { Pagination }; +export { DataTablePlaceholderEmpty, DataTablePlaceholderError } from './table/DataTablePlaceholder'; + + +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 = { + columns, + data: items, + 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, + ); + + const context = React.useMemo>(() => ({ + status: { ready: isReady, loading: false, error: null }, + setStatus() {}, + reload() {}, + table, + }), [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(), []); + + 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]); + + // 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.scss b/src/components/tables/DataTable/DataTableLazy.scss new file mode 100644 index 00000000..21bf3c09 --- /dev/null +++ b/src/components/tables/DataTable/DataTableLazy.scss @@ -0,0 +1,38 @@ + +@use '../../../style/variables.scss' as bkl; +@use '../../../style/mixins.scss' as mixins; + + +@mixin data-table-loading() { + position: relative; + + .bkl-loader { + position: absolute; + top: calc(50% - (80px / 2)); + left: calc(50% - (80px / 2)); + } + + /* + // Loading overlay + &::after { + content: ''; + pointer-events: none; + display: block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background: rgba(bkl.$dark-color-1, 0.2); + border-radius: bkl.$border-radius-ms; + } + */ + + .bkl-data-table__table tbody { + opacity: 0.6; + } +} + +.bkl-data-table-lazy--loading { + @include data-table-loading(); +} diff --git a/src/components/tables/DataTable/DataTableLazy.tsx b/src/components/tables/DataTable/DataTableLazy.tsx new file mode 100644 index 00000000..0a4fc209 --- /dev/null +++ b/src/components/tables/DataTable/DataTableLazy.tsx @@ -0,0 +1,292 @@ + +import * as React from 'react'; +import { classNames as cx, ClassNameArgument } from '../../../util/component_util'; +import { useEffectAsync } from '../../../util/hooks'; + +import { Loader } from '../../overlays/loader/Loader'; +import { Button } from '../../buttons/Button'; + +import * as ReactTable from 'react-table'; +import { DataTableStatus, TableContextState, createTableContext, useTable } from './DataTableContext'; +import { Pagination } from './pagination/Pagination'; +import { DataTablePlaceholderError } from './table/DataTablePlaceholder'; +import { DataTableAsync } from './table/DataTable'; + +import './DataTableLazy.scss'; +import { BaklavaIcon } from '../../icons/icon-pack-baklava/BaklavaIcon'; + + +export * from './DataTableContext'; +export { Pagination }; +export { DataTablePlaceholderEmpty, DataTablePlaceholderError } from './table/DataTablePlaceholder'; +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 = { + columns, + data: items.itemsPage, + getRowId, + }; + 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 ( + { reload(); }}> + Retry + + } + /> + } + {...propsRest} + > + {showLoadingIndicator && } + + ); +}; +DataTableLazy.displayName = 'DataTableLazy'; diff --git a/src/components/tables/DataTable/DataTableStream.scss b/src/components/tables/DataTable/DataTableStream.scss new file mode 100644 index 00000000..e48200ce --- /dev/null +++ b/src/components/tables/DataTable/DataTableStream.scss @@ -0,0 +1,8 @@ + +@use '../../../style/variables.scss' as bkl; +@use './DataTableLazy.scss' as dataTableLazy; + + +.bkl-data-table-stream--loading { + @include dataTableLazy.data-table-loading(); +} diff --git a/src/components/tables/DataTable/DataTableStream.tsx b/src/components/tables/DataTable/DataTableStream.tsx new file mode 100644 index 00000000..4ed1deae --- /dev/null +++ b/src/components/tables/DataTable/DataTableStream.tsx @@ -0,0 +1,531 @@ + +import * as React from 'react'; +import { classNames as cx } from '../../../util/component_util'; + +import { Loader } from '../../overlays/loader/Loader'; +import { Button } from '../../buttons/Button'; + +import * as ReactTable from 'react-table'; +import { DataTableStatus, TableContextState, createTableContext, useTable } from './DataTableContext'; +import { PaginationStream } from './pagination/PaginationStream'; +import { DataTablePlaceholderError } from './table/DataTablePlaceholder'; +import { DataTableAsync } from './table/DataTable'; + +import type { FilterQuery } from '../../../prefab/forms/MultiSearch/MultiSearch'; + +// Table plugins +import { useCustomFilters } from './plugins/useCustomFilters'; + +// Styles +import './DataTableStream.scss'; + + +export * from './DataTableContext'; +export { PaginationStream }; +export { Search, MultiSearch } from './DataTableEager'; // FIXME: move to a common module +export { + DataTablePlaceholderEmpty, + DataTablePlaceholderError, + DataTablePlaceholderEndOfTable, +} from './table/DataTablePlaceholder'; + + +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) + } else { + 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()]; + const lastIndex: undefined | 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, + }; + 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]; + + if (partialPageHistoryItem) { + updatedPageHistory = updatedPageHistory.pop(state.pageIndex); + } + + updatedPageHistory = updatedPageHistory.push(state.pageIndex, { itemLast, pageState }); + } + + // 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 React.useMemo( + () => ({ + ...state, + pageSize, + pageCount, + endOfStream, + partialStream, + }), + [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, + ); + + React.useEffect(() => { + table.dispatch({ type: 'tableLoad' }); + }, []); + + const reload = () => { + table.dispatch({ type: 'reload' }); + }; + + const context = React.useMemo>(() => ({ + status, + setStatus, + reload, + table, + }), [ + JSON.stringify(status), + reload, + // Note: the `table` reference is mutated, so cannot use it as dependency for `useMemo` directly + 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. + 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 ( + { reload(); }}> + Retry + + } + /> + } + 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..aada220d --- /dev/null +++ b/src/components/tables/DataTable/filtering/Filtering.ts @@ -0,0 +1,223 @@ +import { getUnixTime } from 'date-fns'; +import type { + ArrayFieldSpec, + RecordFieldSpec, + FieldQuery, + FilterQuery, + Fields, + Field, + TypeOfFieldSpec, + TypeOfFieldsSpec, +} from '../../../../prefab/forms/MultiSearch/MultiSearch'; + +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(); + } else { + return field; + } +}; + +const numericOperation = (numericField: number, operation: FieldQuery['operation']): boolean => { + if ('$eq' in operation) { + return numericField === operation.$eq; + } else if ('$ne' in operation) { + return numericField !== operation.$ne; + } else if ('$gte' in operation) { + return numericField >= operation.$gte; + } else if ('$gt' in operation) { + return numericField > operation.$gt; + } else if ('$lte' in operation) { + return numericField <= operation.$lte; + } else if ('$lt' in operation) { + return numericField < operation.$lt; + } else if ('$range' in operation) { + return numericField >= operation.$range[0] && numericField <= operation.$range[1]; + } else { + 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()); + } else { + 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) { + return fieldAsArray.every(field => (operation.$eq as typeof fieldAsArray).indexOf(field) >= 0); + } else if ('$ne' in operation) { + return fieldAsArray.every(field => (operation.$ne as typeof fieldAsArray).indexOf(field) < 0); + } else if ('$all' in operation) { + const elementFieldSpec = fieldSpec.subfield; + return fieldAsArray.every(element => { + if ('$and' in operation.$all && Array.isArray(operation.$all.$and)) { + const operations = operation.$all.$and; + return operations.every(operation => matchesFieldQuery(elementFieldSpec, element, operation)); + } else if ('$or' in operation.$all && Array.isArray(operation.$all.$or)) { + const operations = operation.$all.$or; + return operations.some(operation => matchesFieldQuery(elementFieldSpec, element, operation)); + } else { + throw new TypeError(`Unsupported array operation`); + } + }); + } else if ('$any' in operation) { + return fieldAsArray.some(element => { + const elementFieldSpec = fieldSpec.subfield; + if ('$and' in operation.$any && Array.isArray(operation.$any.$and)) { + const operations = operation.$any.$and; + return operations.every(operation => matchesFieldQuery(elementFieldSpec, element, operation)); + } else if ('$or' in operation.$any && Array.isArray(operation.$any.$or)) { + const operations = operation.$any.$or; + return operations.some(operation => matchesFieldQuery(elementFieldSpec, element, operation)); + } else { + throw new TypeError(`Unsupported array operation`); + } + }); + } else { + throw new TypeError(`Unknown query operator`); + } + } + case 'dictionary': { + const fieldAsDictionary = field as string; // Unsafe but guaranteed by `S` + + if ('$all' in operation) { + return fieldAsDictionary.includes(Object.values(operation.$all)[0]); + } else { + 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; + } else if ('$nin' in operation) { + return operation.$nin.indexOf(fieldAsEnum) === -1; + } else if ('$eq' in operation) { + return fieldAsEnum.includes(operation.$eq as string); + } else if ('$ne' in operation) { + return !fieldAsEnum.includes(operation.$ne as string); + } else { + 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 => { + 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)); + } else if ('$or' in operation.$all && Array.isArray(operation.$all.$or)) { + const operations = operation.$all.$or; + return operations.some(operation => matchesFieldQuery(elementFieldSpec, element, operation)); + } else { + const fieldName: keyof RecordFieldSpec['fields'] = Object.keys(operation.$all)[0]; + const operations: FieldQuery['operation'] = Object.values(operation.$all)[0]; + if (typeof element === 'object' && element !== null && !Array.isArray(element)) { + const item = element as Record; + return matchesFieldQuery(elementFieldSpec, item[fieldName], operations); + } else { + return matchesFieldQuery(elementFieldSpec, element, operations); + } + } + }); + } else if ('$any' in operation) { + return Object.values(fieldAsRecord).some(element => { + 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)); + } else if ('$or' in operation.$any && Array.isArray(operation.$any.$or)) { + const operations = operation.$any.$or; + return operations.some(operation => matchesFieldQuery(elementFieldSpec, element, operation)); + } else { + const fieldName: keyof RecordFieldSpec['fields'] = Object.keys(operation.$any)[0]; + const operations: FieldQuery['operation'] = Object.values(operation.$any)[0]; + if (typeof element === 'object' && element !== null && !Array.isArray(element)) { + const item = element as Record; + return matchesFieldQuery(elementFieldSpec, item[fieldName], operations); + } else { + return matchesFieldQuery(elementFieldSpec, element, operations); + } + } + }); + } else { + 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); + } else if (fieldName !== '') { + return item[fieldName]; + } else { + 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; } + const fieldSpec: Field = spec[fieldName]; + const fieldValue = getFieldValue(fieldSpec, item, fieldName) as TypeOfFieldSpec; + return matchesFieldQuery(fieldSpec, fieldValue, fieldQuery.operation); + }); + }) + .reduce( + (itemsAsRecord, [itemId, item]) => { + itemsAsRecord[itemId] = item; + return itemsAsRecord; + }, + {} as Record, + ); + return itemsFiltered; + } else { + return items; + } +}; diff --git a/src/components/tables/DataTable/pagination/Pagination.scss b/src/components/tables/DataTable/pagination/Pagination.scss new file mode 100644 index 00000000..1e0f1274 --- /dev/null +++ b/src/components/tables/DataTable/pagination/Pagination.scss @@ -0,0 +1,76 @@ + +@use '../../../../style/variables.scss' as bkl; +@use '../../../../style/mixins.scss' as mixins; + + +.pagination { + display: flex; + align-items: center; + justify-content: flex-end; + gap: bkl.$sizing-m; + + color: bkl.$neutral-color-3; + @include mixins.font($family: bkl.$font-family-display, $weight: bkl.$font-weight-regular); + font-size: bkl.$font-size-s; + + .pager.pager--indexed { + display: flex; + align-items: center; + gap: 1rem; + + .pager__nav { + font-size: 0.6rem; + + @include mixins.circle(2.4rem); + padding: bkl.$sizing-2; + background: bkl.$accent-color; + color: bkl.$light-color-1; + + &:not(.disabled) { + cursor: pointer; + } + &.disabled { + background: rgba(bkl.$dark-color-2, 0.34); + } + } + + .pager__indices { + display: flex; + align-items: center; + gap: 0.5rem; + + li { + user-select: none; + + padding: 3px 7px; + color: #4D758C; + + &:not(.pager__indices__separator) { + border-radius: bkl.$border-radius-s; + + transition: background-color 150ms ease-in; + &:hover { + background-color: rgba(bkl.$neutral-color-4, 0.5); + } + &.active { + background-color: bkl.$neutral-color-4; + } + &:not(.active) { + cursor: pointer; + } + } + &.pager__indices__separator { + padding-inline: 0; + } + } + } + } +} + + +@media only screen and (max-width: 1100px) { + .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..b08e7233 --- /dev/null +++ b/src/components/tables/DataTable/pagination/Pagination.tsx @@ -0,0 +1,128 @@ + +import cx from 'classnames'; +import * as React from 'react'; +import { joinElements } from '../../../../util/component_util'; + +import { SpriteIcon as Icon } from '../../../icons/Icon'; + +import { PageSizeOption, PaginationSizeSelector } from './PaginationSizeSelector'; +import { useTable } from '../DataTableContext'; + +import './Pagination.scss'; + + +type PageOptionsSegment = Array; // Consecutive list of page indices (e.g. `[5, 6, 7]`) +type PageOptions = Array; // List of segments (e.g. `[[1], [49, 50, 51], [100]]`) +const combineSegments = (segment1: PageOptionsSegment, segment2: PageOptionsSegment): PageOptions => { + if (segment1.length === 0 || segment2.length === 0) { return [[...segment1, ...segment2]]; } + + const gapLeft = segment1.slice(-1)[0]; // Element to the left side of the gap (i.e. the last element of `segment1`) + const gapRight = segment2[0]; // Element to the right of the gap (i.e. the first element of `segment2`) + const gapSize = gapRight - gapLeft - 1; // Calculate the gap (if 0, then the segments are consecutive, e.g. 3 to 4) + + if (gapSize > 1) { + // If there is a gap between the segments larger than one, leave unmerged + return [segment1, segment2]; + } else if (gapSize === 1) { + // If the gap is 1 (i.e. there is only one element "missing" in between), fill it in and merge + // Motivation: there will be a separator between gaps (e.g. `4 5 ... 7 8`), so if there is only element in between, + // then it makes sense to replace the separator with the missing element explicitly. + return [[...segment1, gapLeft + 1, ...segment2]]; + } else { + // If there is no gap, combine the two segments (removing any overlapping elements) + return [[...segment1, ...segment2.filter((pageIndex: number) => pageIndex > gapLeft)]]; + } +}; +const getPageOptions = ({ pageCount, pageIndex }: { pageCount: number, pageIndex: number }): PageOptions => { + const pageIndexFirst = 0; + const pageIndexLast = pageCount - 1; + + // Basic template for page options + const template = [ + [pageIndexFirst, pageIndexFirst + 1], // First two pages + [pageIndex - 1, pageIndex, pageIndex + 1], // Current page, plus immediate predecessor and successor + [pageIndexLast - 1, pageIndexLast], // Last two pages + ]; + + return template.reduce( + (pageOptions: PageOptions, segmentTemplate: PageOptionsSegment): PageOptions => { + // Filter out any invalid page indices from the template + const segment: PageOptionsSegment = segmentTemplate.filter((pageIndex: number) => { + return pageIndex >= pageIndexFirst && pageIndex <= pageIndexLast; + }); + + if (pageOptions.length === 0) { return [segment]; } + + // Split `pageOptions` into its last segment, and everything before: `[...pageOptionsBase, segmentPrior]` + const pageOptionsBase: PageOptions = pageOptions.slice(0, -1); + const segmentPrior: PageOptionsSegment = pageOptions.slice(-1)[0]; + + // Attempt to combine `segmentPrior` and `segment` into one consecutive segment (if there's no gap in between) + return [...pageOptionsBase, ...combineSegments(segmentPrior, segment)]; + }, + [], + ); +}; + +type PaginationProps = { + pageSizeOptions?: Array, +}; +export const Pagination = ({ pageSizeOptions }: PaginationProps) => { + const { table } = useTable(); + + /* + Available pagination state: + - table.state.pageIndex + - table.state.pageSize + - table.canPreviousPage + - table.canPreviousPage + - table.canNextPage + - table.pageOptions + - table.pageCount + - table.gotoPage + - table.nextPage + - table.previousPage + - table.setPageSize + */ + + const pageCount = Math.max(table.pageCount, 1); // Note: for an empty table `react-table` will return pageCount = 0 + const pageIndex = table.state.pageIndex; + const pageOptions: PageOptions = getPageOptions({ pageCount, pageIndex }); + + return ( +
+ + +
+ + +
    + {joinElements( // Join the segments together with separator
  • elements inserted in between +
  • , + pageOptions + .map((pageOptionsSegment: PageOptionsSegment) => + <> + {pageOptionsSegment.map((pageIndex: number) => +
  • { table.gotoPage(pageIndex); }} + > + {pageIndex + 1} +
  • , + )} + , + ), + )} +
+ + +
+
+ ); +}; diff --git a/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss b/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss new file mode 100644 index 00000000..0f46a980 --- /dev/null +++ b/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss @@ -0,0 +1,36 @@ + +@use '../../../../style/variables.scss' as bkl; +@use '../../../../style/mixins.scss' as mixins; + + +.page-size-selector { + flex: none; + + display: flex; + align-items: center; + + .page-size-selector__page-size { + user-select: none; + flex: none; + + padding: bkl.$sizing-xs; + + display: flex; + align-items: center; + gap: bkl.$sizing-xs; + + @include mixins.font($family: bkl.$font-family-display, $weight: bkl.$font-weight-regular); + + .icon-caret { + width: 0.8em; + } + } + + @at-root .page-size-selector__selector { + min-width: 6rem; + .bkl-dropdown__menu > .bkl-dropdown__menu-item { + padding-block: bkl.$sizing-xxs; + text-align: right; + } + } +} diff --git a/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx b/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx new file mode 100644 index 00000000..cbae7a67 --- /dev/null +++ b/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx @@ -0,0 +1,53 @@ + +import cx from 'classnames'; +import * as React from 'react'; + +import { SpriteIcon as Icon } from '../../../icons/Icon'; +import { Button } from '../../../buttons/Button'; +import { Dropdown } from '../../../overlays/dropdown/Dropdown'; + +import { useTable } from '../DataTableContext'; + +import './PaginationSizeSelector.scss'; + + +export type PageSizeOption = number; +export const defaultPageSizeOptions: Array = [10, 25, 50, 100]; + +type PaginationSizeSelectorProps = { + pageSizeOptions?: Array, + pageSizeLabel?: string; +}; +export const PaginationSizeSelector = (props: PaginationSizeSelectorProps) => { + const { pageSizeOptions = defaultPageSizeOptions, pageSizeLabel = 'Items per page' } = props; + + const { table } = useTable(); + + return ( +
+ {pageSizeLabel}: + + + {table.state.pageSize} + + + } + > + {({ close }) => + pageSizeOptions.map(pageSize => + { table.setPageSize(pageSize); close(); }} + > + {pageSize} + , + ) + } + +
+ ); +}; diff --git a/src/components/tables/DataTable/pagination/PaginationStream.scss b/src/components/tables/DataTable/pagination/PaginationStream.scss new file mode 100644 index 00000000..9abd0679 --- /dev/null +++ b/src/components/tables/DataTable/pagination/PaginationStream.scss @@ -0,0 +1,56 @@ + +@use '../../../../style/variables.scss' as bkl; +@use '../../../../style/mixins.scss' as mixins; + +@use './Pagination.scss'; + + +.icon-double-chevron-left { + display: flex; + align-items: center; + + // Add some slight kerning + .icon:last-of-type { + margin-left: -0.1em; + } +} + +.pagination--stream { + .pagination__load-more-action { + display: flex; + align-items: center; + margin-right: auto; + } + + .pagination__pager { + display: flex; + align-items: center; + gap: bkl.$sizing-s; + + .pager__nav { + &.pager__nav--first { + @include mixins.circle($size: 3.5rem); + background: bkl.$light-color-1; + padding: bkl.$sizing-s; + + .icon { + // width: calc(var(--icon-size) * 0.8); + width: 0.6em; + } + } + + &.pager__nav--prev, &.pager__nav--next { + min-width: 12rem; + + display: flex; + align-items: center; + gap: bkl.$sizing-xs; + + .icon { + // width: calc(var(--icon-size) * 0.8); + width: 0.6em; + } + } + } + } +} diff --git a/src/components/tables/DataTable/pagination/PaginationStream.tsx b/src/components/tables/DataTable/pagination/PaginationStream.tsx new file mode 100644 index 00000000..9739da3b --- /dev/null +++ b/src/components/tables/DataTable/pagination/PaginationStream.tsx @@ -0,0 +1,79 @@ + +import cx from 'classnames'; +import * as React from 'react'; + +import { SpriteIcon as Icon } from '../../../icons/Icon'; +import { Button } from '../../../buttons/Button'; + +import { PageSizeOption, PaginationSizeSelector } from './PaginationSizeSelector'; +import { useTable } from '../DataTableContext'; + +import './PaginationStream.scss'; + + +type IconDoubleChevronLeftProps = React.ComponentPropsWithoutRef<'span'> & { + iconProps?: Partial>, +}; +const IconDoubleChevronLeft = ({ iconProps = {}, ...props }: IconDoubleChevronLeftProps) => { + return ( + + + + + ); +}; + +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..03952954 --- /dev/null +++ b/src/components/tables/DataTable/plugins/useCustomFilters.tsx @@ -0,0 +1,46 @@ + +import * as React from 'react'; +import * as ReactTable from 'react-table'; + + +// 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 { + state: { customFilters }, + dispatch, + } = instance; + + const setCustomFilters = React.useCallback( + customFilters => { + 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.scss b/src/components/tables/DataTable/plugins/useRowSelectColumn.scss new file mode 100644 index 00000000..97af459b --- /dev/null +++ b/src/components/tables/DataTable/plugins/useRowSelectColumn.scss @@ -0,0 +1,15 @@ + +@use '../../../../style/variables.scss' as bkl; + +.bkl-data-table-row-select { + width: bkl.$sizing-7 + bkl.$sizing-1; + max-width: bkl.$sizing-7 + bkl.$sizing-1; + + &__header { + margin-bottom: bkl.$sizing-m; + } + + &__cell { + margin-bottom: bkl.$sizing-m; + } +} \ No newline at end of file diff --git a/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx b/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx new file mode 100644 index 00000000..9fca78fc --- /dev/null +++ b/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx @@ -0,0 +1,35 @@ + +import * as React from 'react'; +import * as ReactTable from 'react-table'; +import { Checkbox } from '../../../forms/checkbox/Checkbox'; + +import './useRowSelectColumn.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: 'bkl-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.scss b/src/components/tables/DataTable/table/DataTable.scss new file mode 100644 index 00000000..7c13685c --- /dev/null +++ b/src/components/tables/DataTable/table/DataTable.scss @@ -0,0 +1,180 @@ + +@use '../../../../style/variables.scss' as bkl; +@use '../../../../style/mixins.scss' as mixins; + + +.bkl-data-table { + width: 100%; + overflow-x: auto; + + table.bkl-data-table__table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + + th { + padding: 1.5rem 0.5rem; + + 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: 1.5rem 0.5rem; + + 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 bkl.$neutral-color-1; + + th { + cursor: default; + padding-bottom: 1rem; + + color: bkl.$neutral-color-3; + @include mixins.font($family: bkl.$font-family-display, $weight: bkl.$font-weight-semibold); + font-size: bkl.$font-size-xs; + text-transform: uppercase; + + .column-header { + display: flex; + align-items: baseline; + + .sort-indicator { + align-self: baseline; // Override .bkl-icon + margin-left: 0.8rem; + width: 1rem; + + &.sort-indicator--inactive { + opacity: 0.3; + } + + 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) + } + } + } + } + } + + tbody { + tr:not(.bkl-data-table__placeholder) { + &:not(:last-of-type) { + border-bottom: 1px solid bkl.$neutral-color-1; + } + + &:hover { + background-color: bkl.$light-color-2; + + // Add rounded edges to the sides of the table row + border-radius: bkl.$border-radius-s; + box-shadow: -0.7rem 0 0 0 bkl.$light-color-2, 0.7rem 0 0 0 bkl.$light-color-2; + + // Workaround for WebKit, where box-shadow on `tr` does not work: + // https://stackoverflow.com/questions/5972525/table-row-box-shadow-on-hover-webkit + // Note: the following does not seem to work in Chrome, so we need both `tr` and `td` solutions + > td:first-child { + border-top-left-radius: bkl.$border-radius-s; + border-bottom-left-radius: bkl.$border-radius-s; + box-shadow: -0.7rem 0 0 0 bkl.$light-color-2; + } + > td:last-child { + border-top-right-radius: bkl.$border-radius-s; + border-bottom-right-radius: bkl.$border-radius-s; + box-shadow: 0.7rem 0 0 0 bkl.$light-color-2; + } + } + + // Item cell + > td { + font-size: bkl.$font-size-s; + + .text-minor { + color: bkl.$brand-color; + font-size: bkl.$font-size-xs; + } + + :any-link:not(.bkl-link--plain) { + color: bkl.$dark-color-1; + + &:hover { + text-decoration: underline; + } + } + + .bkl-data-table__item__name { + @include mixins.font($family: bkl.$font-family-display, $weight: bkl.$font-weight-medium); + } + @at-root .bkl-data-table tr:hover .bkl-data-table__item__name { text-decoration: underline; } + } + } + } + + tfoot { + td { + overflow: visible; // Allow overflow due to dropdown menu + } + } + } +} + +@media only screen and (width <= 1100px) { + table.bkl-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(.bkl-data-table__placeholder) { + margin: 1.5rem 0; + padding: 1.5rem; + border-radius: bkl.$border-radius-ms; + background: bkl.$light-color-2; + box-shadow: 0.1rem 0.1rem 0.1rem rgba(bkl.$brand-color-dark-1, 0.15); + + &:not(:last-of-type) { + border-bottom: none; + } + + &:hover { + box-shadow: 0.1rem 0.1rem 0.1rem rgba(bkl.$brand-color-dark-1, 0.15); // Override desktop `:hover` + } + + > 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..d175ef6f --- /dev/null +++ b/src/components/tables/DataTable/table/DataTable.tsx @@ -0,0 +1,244 @@ + +import * as React from 'react'; +import { classNames as cx, type ClassNameArgument, type ComponentProps } from '../../../../util/componentUtil.ts'; +import * as ReactTable from 'react-table'; + +import { Icon } from '../../../graphics/Icon/Icon.tsx'; + +import { + DataTablePlaceholderSkeleton, + DataTablePlaceholderEmpty, + DataTablePlaceholderError, +} from './DataTablePlaceholder.tsx'; +import type { DataTableStatus } from '../DataTableContext.tsx'; + +//import './DataTable.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 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} +
+ ); +}; +DataTable.displayName = 'DataTable'; + + +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 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'; + + +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 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} + + +
+ ); +}; +DataTableAsync.displayName = 'DataTableAsync'; diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.scss b/src/components/tables/DataTable/table/DataTablePlaceholder.scss new file mode 100644 index 00000000..e1315386 --- /dev/null +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.scss @@ -0,0 +1,96 @@ + +@use '../../../../style/variables.scss' as bkl; +@use '../../../../style/mixins.scss' as mixins; + + +.bk-table-placeholder { + min-height: 40rem; + padding: bkl.$sizing-l; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + color: bkl.$brand-color-dark-2; + @include mixins.font($family: bkl.$font-family-display, $weight: bkl.$font-weight-regular); + font-size: bkl.$font-size-l; + + .bk-table-placeholder__icon { + box-sizing: content-box; // Have padding increase the size rather than shrink the content + width: 1.8em; + height: 1.8em; + margin-bottom: bkl.$sizing-m; + padding: 1em; + } + + .bk-table-placeholder__message { + margin: 0; + } + + .bk-table-placeholder__actions { + margin: 0; + margin-top: bkl.$sizing-l; + + display: flex; + gap: bkl.$sizing-s; + } + + &.bk-table-placeholder--error { + color: bkl.$status-color-error; + + .bk-table-placeholder--error__error-icon { + position: relative; + + .icon-cross { + position: absolute; + top: 1.4rem; + right: 1.6rem; + + @include mixins.circle($size: 1.8rem); + background: bkl.$status-color-error; + color: bkl.$light-color-1; + + border: 0.2rem solid bkl.$light-color-1; + } + } + } +} + +.bk-table-row-placeholder { + background: hsla(37, 91%, 55%, 0.04); + border: 1px solid #F5A623; + padding: bkl.$sizing-3 (bkl.$sizing-4 + bkl.$sizing-1); + height: 5.2rem; + display: flex; + align-items: center; + + .bk-table-row-placeholder__icon { + color: #F5A623; + margin-right: bkl.$sizing-4 + bkl.$sizing-1; + } + + .bk-table-row-placeholder__message { + @include mixins.font( + $family: bkl.$font-family-body, + $weight: bkl.$font-weight-regular, + $size: bkl.$font-size-s, + ); + line-height: bkl.$line-height-4; + color: rgba(12, 12, 34, 0.87); + } +} + +.bk-table-placeholder.bk-table-placeholder--skeleton { + align-items: stretch; + justify-content: space-around; + gap: 1rem; + + > .skeleton-row { + display: block; + height: 1.5rem; + border-radius: bkl.$border-radius-ms; + + @include mixins.shimmer($baseColor: bkl.$neutral-color-4, $highlightColor: bkl.$light-color-2); + } +} diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.tsx b/src/components/tables/DataTable/table/DataTablePlaceholder.tsx new file mode 100644 index 00000000..3c549680 --- /dev/null +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.tsx @@ -0,0 +1,188 @@ + +import * as React from 'react'; +import { classNames as cx, type ClassNameArgument, type ComponentProps } from '../../../../util/componentUtil.ts'; +import { type IconKey, isIconKey } from '../../../../assets/icons/_icons.ts'; +import { Icon, type IconProps } from '../../../graphics/Icon/Icon.tsx'; + +//import './DataTablePlaceholder.scss'; + + +type DataTablePlaceholderProps = ComponentProps<'div'> & { + icon?: IconKey | React.ReactNode, + classNameIcon?: ClassNameArgument, + classNameMessage?: ClassNameArgument, + classNameActions?: ClassNameArgument, + placeholderMessage: React.ReactNode, + actions?: React.ReactNode, +}; +export const DataTablePlaceholder = (props: DataTablePlaceholderProps) => { + const { icon, classNameIcon, classNameMessage, classNameActions, placeholderMessage, actions, ...propsRest } = props; + + const decoration = React.useMemo(() => ({ type: 'background-circle' } as const), []); + const renderStandardIcon = (icon: IconProps['icon']): React.ReactNode => { + return ( + + ); + }; + + const renderIcon = (): React.ReactNode => { + if (typeof icon === 'undefined') { + return renderStandardIcon('view-type-table'); + } + if (typeof icon === 'string') { + if (isIconKey(icon)) { + return renderStandardIcon(icon); + } + throw new Error(`Invalid icon ${icon}`); + } + return icon; + }; + + return ( +
+ {renderIcon()} + +

+ {placeholderMessage} +

+ + {actions && +

+ {actions} +

+ } +
+ ); +}; +DataTablePlaceholder.displayName = 'DataTablePlaceholder'; + + +// Loading skeleton (when there's no data to show yet) +type DataTablePlaceholderSkeletonProps = { className?: ClassNameArgument }; +export const DataTablePlaceholderSkeleton = (props: DataTablePlaceholderSkeletonProps) => { + return ( +
+ {Array.from({ length: 6 }).map((_, index) => + // biome-ignore lint/suspicious/noArrayIndexKey: no other unique identifier available + , + )} +
+ ); +}; +DataTablePlaceholderSkeleton.displayName = 'DataTablePlaceholderSkeleton'; + + +// Empty table (ready but no data) +type DataTablePlaceholderEmptyProps = Omit & { + // Make `placeholderMessage` optional + placeholderMessage?: DataTablePlaceholderProps['placeholderMessage'], +}; +export const DataTablePlaceholderEmpty = (props: DataTablePlaceholderEmptyProps) => { + return ( + + ); +}; +DataTablePlaceholderEmpty.displayName = 'DataTablePlaceholderEmpty'; + + +type DataTableErrorIconProps = Omit & { + icon?: IconProps['icon'], +}; +export const DataTableErrorIcon = (props: DataTableErrorIconProps) => { + const decoration = React.useMemo(() => ({ type: 'background-circle' } as const), []); + return ( +
+ + +
+ ); +}; +DataTableErrorIcon.displayName = 'DataTableErrorIcon'; + +type DataTablePlaceholderErrorProps = Omit & { + // Make `placeholderMessage` optional + placeholderMessage?: React.ComponentProps['placeholderMessage'], +}; +export const DataTablePlaceholderError = (props: DataTablePlaceholderErrorProps) => { + return ( + } + placeholderMessage="Failed to load items" + {...props} + className={cx('bk-table-placeholder--error', props.className)} + /> + ); +}; +DataTablePlaceholderError.displayName = 'DataTablePlaceholderError'; + + +type DataTableRowPlaceholderProps = ComponentProps<'div'> & { + icon?: IconKey | React.ReactNode, + classNameIcon?: ClassNameArgument, + classNameMessage?: ClassNameArgument, + classNameActions?: ClassNameArgument, + placeholderMessage: React.ReactNode, + actions?: React.ReactNode, +}; +export const DataTableRowPlaceholder = (props: DataTableRowPlaceholderProps) => { + const { icon, classNameIcon, classNameMessage, classNameActions, placeholderMessage, actions, ...propsRest } = props; + + const renderStandardIcon = (icon: IconProps['icon']): React.ReactNode => { + return ( + + ); + }; + + const renderIcon = (): React.ReactNode => { + if (typeof icon === 'undefined') { + return renderStandardIcon('event-warning'); + } + if (typeof icon === 'string') { + if (isIconKey(icon)) { + return renderStandardIcon(icon); + } + throw new Error(`Invalid icon ${icon}`); + } + return icon; + }; + + return ( +
+ {renderIcon()} + +

+ {placeholderMessage} +

+ + {actions && +

+ {actions} +

+ } +
+ ); +}; +DataTableRowPlaceholder.displayName = 'DataTableRowPlaceholder'; + +type DataTablePlaceholderEndOfTableProps = Omit & { + // Make `placeholderMessage` optional + placeholderMessage?: React.ReactNode, +}; + +export const DataTablePlaceholderEndOfTable = (props: DataTablePlaceholderEndOfTableProps) => { + return ( + + ); +}; +DataTablePlaceholderEndOfTable.displayName = 'DataTablePlaceholderEndOfTable'; diff --git a/src/components/tables/MultiSearch/MultiSearch.scss b/src/components/tables/MultiSearch/MultiSearch.scss new file mode 100644 index 00000000..87bc455c --- /dev/null +++ b/src/components/tables/MultiSearch/MultiSearch.scss @@ -0,0 +1,149 @@ + +@use '../../../style/variables.scss' as *; +@use '../../../components/overlays/dropdown/Dropdown.scss'; + + +.bkl-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; + + &.bkl-search-input--active { + background-color: $light-color-1; + border-color: $accent-color-light-2; + } + + &:hover:not(.bkl-search-input--active) { + background-color: $light-color-1; + border-color: rgba($accent-color-light-2, 0.33); + outline: none; + } + + .bkl-input { + .bkl-input__input { + background-color: transparent; + border: none; + } + } + + .bkl-search-input__search-icon, .bkl-search-input__search-key { + padding: 1rem; + padding-right: $sizing-none; + } + + .bkl-search-input__search-icon { + width: $sizing-m; + opacity: 0.5; + color: #8AA1B0; + } + + .bkl-search-input__search-key { + font-size: $font-size-s; + font-weight: $font-weight-light; + line-height: $line-height-2; + flex: 1 0 auto; + } + + .bkl-search-input__input { + width: 100%; + } +} + +.bkl-multi-search__filters { + display: flex; + margin-top: $sizing-s; + + .bkl-multi-search__filters-wrapper { + display: flex; + flex-wrap: wrap; + gap: $sizing-s; + + .bkl-multi-search__filter { + .filter-operand { + margin-left: $sizing-xxs; + } + + .filter-value { + margin-left: $sizing-xxs; + font-weight: $font-weight-semibold; + } + } + } + + .bkl-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; + } + } + } +} + +.bkl-multi-search__operators { + .operator { + display: flex; + justify-content: center; + } +} + +.bkl-multi-search__alternatives { + .bkl-multi-search__alternatives-group { + .bkl-checkbox { + padding: $sizing-s; + } + } + + .bkl-multi-search__alternatives-action { + padding-top: $sizing-s; + display: flex; + justify-content: center; + } +} + +.bkl-multi-search__date-time { + .bkl-multi-search__date-time-group { + .bkl-multi-search__date-time-label { + margin-bottom: $sizing-xs; + font-weight: $font-weight-semibold; + } + + padding: $sizing-s; + } + + .bkl-multi-search__date-time-action { + padding-top: $sizing-s; + display: flex; + justify-content: center; + } +} + + +.bkl-multi-search__suggested-keys { + .bkl-multi-search__suggested-key-input .bkl-input__input { + width: auto; + } +} + +.bkl-multi-search__error-msg, +.bkl-multi-search__dropdown-error-msg { + padding-top: $sizing-s; + color: $status-color-error; + max-width: $sizing-6 * 10; + display: block; +} + +.bkl-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..506296b6 --- /dev/null +++ b/src/components/tables/MultiSearch/MultiSearch.stories.tsx @@ -0,0 +1,238 @@ + +import { getDay as dateGetDay, startOfDay as dateStartOfDay, endOfDay as dateEndOfDay, sub as dateSub } from 'date-fns'; + +import * as React from 'react'; +import * as StorybookKnobs from '@storybook/addon-knobs'; + +import { StoryMetadata } from '../../../types/storyMetadata'; + +import { Panel } from '../../../components/containers/panel/Panel'; +import * as MultiSearch from './MultiSearch'; + + +export default { + title: 'Prefab/Forms/MultiSearch', + decorators: [ + StorybookKnobs.withKnobs, + renderStory => {renderStory()}, + ], + component: MultiSearch, +} as StoryMetadata; + +export const Standard = () => { + const severityFieldSpec: MultiSearch.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: MultiSearch.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: MultiSearch.TextFieldSpec = { + type: 'text', + operators: ['$text'], + label: 'Initiator', + placeholder: 'Search initiator', + }; + + const countFieldSpec: MultiSearch.Field = { + type: 'number', + operators: ['$eq', '$lt', '$lte', '$gt', '$gte', '$ne'], + label: 'Count', + placeholder: 'Search ip-address', + }; + + const customAttributesFieldSpec: MultiSearch.DictionaryFieldSpec = { + type: 'dictionary', + operators: ['$all'], + label: 'Custom Attributes', + suggestedKeys: { + vehicle: { label: 'Vehicle' }, + book: { label: 'Book' }, + }, + }; + + const createdAtFieldSpec: MultiSearch.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: MultiSearch.FilterQuery) => setFilters(filter); + + return ( + + ); +}; + +export const WithValidation = () => { + const uuidFieldSpec: MultiSearch.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: MultiSearch.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: MultiSearch.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: MultiSearch.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: MultiSearch.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: MultiSearch.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: MultiSearch.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..ca4e00b2 --- /dev/null +++ b/src/components/tables/MultiSearch/MultiSearch.tsx @@ -0,0 +1,2285 @@ + +import * as Random from '../../../util/random'; +import * as ObjectUtil from '../../../util/object_util'; +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, ClassNameArgument, ComponentPropsWithoutRef } from '../../../util/component_util'; +import * as Popper from 'react-popper'; + +import { useOutsideClickHandler } from '../../../util/hooks/useOutsideClickHandler'; + +import { BaklavaIcon } from '../../../components/icons/icon-pack-baklava/BaklavaIcon'; +import Input from '../../../components/forms/input/Input'; +import * as Dropdown from '../../../components/overlays/dropdown/Dropdown'; +import { Tag } from '../../../components/containers/tag/Tag'; +import { useCombinedRefs } from '../../../util/hooks/useCombinedRefs'; +import { useFocus } from '../../../util/hooks/useFocus'; +import Checkbox from '../../../components/forms/checkbox/Checkbox'; +import { Button } from '../../../components/buttons/Button'; +import { DateTimePicker } from '../../../components/forms/datetime/DateTimePicker'; +import { Caption } from '../../../components/typography/caption/Caption'; + +import './MultiSearch.scss'; + + +// Suggestions dropdown +const SuggestionItem = Dropdown.Item; + +export type SuggestionProps = Omit, 'children'> & { + children: React.ReactNode | ((props: { close: () => void }) => React.ReactNode), + elementRef?: React.RefObject, // Helps to toggle multiple dropdowns on the same reference element + active?: boolean, + withArrow?: boolean, + primary?: boolean, + secondary?: boolean, + basic?: boolean, + popperOptions?: Dropdown.PopperOptions, + onOutsideClick?: () => void, + containerRef?: 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)} + + ); +}; + +// Utility +type ValueOf> = T[number]; + +// Operators +const enumFieldOperators = ['$in', '$nin', '$eq', '$ne'] as const; +type EnumFieldOperator = ValueOf; + +const arrayFieldOperators = ['$eq', '$ne', '$all', '$any'] as const; +type ArrayFieldOperator = ValueOf; + +const textFieldOperators = ['$eq', '$text'] as const; +type TextFieldOperator = ValueOf; + +const numberFieldOperators = ['$eq', '$gt', '$gte', '$lt', '$lte', '$ne'] as const; +type NumberFieldOperator = ValueOf; + +const dictionaryFieldOperators = ['$all'] as const; +type DictionaryFieldOperators = ValueOf; + +const recordFieldOperators = ['$all', '$any'] as const; +type RecordFieldOperators = ValueOf; + +const dateTimeFieldOperators = ['$eq', '$gt', '$gte', '$lt', '$lte', '$ne', '$range'] as const; +type DateTimeFieldOperator = ValueOf; + +const operators = [ + ...enumFieldOperators, + ...arrayFieldOperators, + ...textFieldOperators, + ...numberFieldOperators, + ...dictionaryFieldOperators, + ...dateTimeFieldOperators, +] as const; +type Operator = ValueOf; + +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: Operator, field: Field) => { + let label = ''; + + if (field.operatorInfo && operator in field.operatorInfo) { + label = field.operatorInfo[operator]?.label ?? ''; + } else if (field.type === 'array') { + if (operator in arrayOperatorsToSymbolMap) { + label = arrayOperatorsToSymbolMap[operator as ArrayFieldOperator]; + } + } else if (field.type === 'enum') { + if (operator in enumOperatorsToSymbolMap) { + label = enumOperatorsToSymbolMap[operator as EnumFieldOperator]; + } + } else if (field.type === 'number') { + if (operator in numberOperatorsToSymbolMap) { + label = numberOperatorsToSymbolMap[operator as NumberFieldOperator]; + } + } else if (field.type === 'datetime') { + if (operator in dateTimeFieldOperatorsToSymbolMap) { + label = dateTimeFieldOperatorsToSymbolMap[operator as DateTimeFieldOperator]; + } + } + + return label; +}; + +// Field specification +type Alternative = { label: string }; +type Alternatives = Record; +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 +}; + +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: any) => R; + +export type EnumFieldSpec = { + type: 'enum', + label: React.ReactNode, + operators: Array, + alternatives: Alternatives, + placeholder?: string, + operatorInfo?: OperatorInfo, + validator?: EnumValidator, + accessor?: Accessor, +}; +export type ArrayFieldSpec = { + type: 'array', + label: React.ReactNode, + operators: Array, + subfield: EnumFieldSpec | NumberFieldSpec, + placeholder?: string, + operatorInfo?: OperatorInfo, + validator?: ArrayValidator, + accessor?: Accessor, +}; +export type TextFieldSpec = { + type: 'text', + label: React.ReactNode, + operators: Array, + placeholder?: string, + operatorInfo?: OperatorInfo, + validator?: TextValidator, + accessor?: Accessor, +}; +export type NumberFieldSpec = { + type: 'number', + label: React.ReactNode, + operators: Array, + placeholder?: string, + operatorInfo?: OperatorInfo, + validator?: TextValidator, + accessor?: Accessor, +}; + +type DateType = Date | number; +export type SelectedDate = DateType | [DateType, DateType]; +export type OnAddFilter = (newFilter: FieldQuery, currentFilters: FilterQuery) => FilterQuery; + +export type DateTimeFieldSpec = { + type: 'datetime', + label: React.ReactNode, + operators: Array, + placeholder?: string, + selectedDate?: SelectedDate, + onAddFilter?: OnAddFilter, + maxDate?: Date | number, + minDate?: Date | number, + operatorInfo?: OperatorInfo, + validator?: DateTimeValidator, + accessor?: Accessor, +}; +type SuggestedKey = { label: string }; +type SuggestedKeys = { [key: string]: SuggestedKey }; +export type DictionaryFieldSpec = { + type: 'dictionary', + label: React.ReactNode, + operators: Array, + suggestedKeys?: SuggestedKeys, + placeholder?: string, + operatorInfo?: OperatorInfo, + validator?: DictionaryValidator, + accessor?: Accessor>, +}; +export type RecordFieldSpec = { + type: 'record', + label: React.ReactNode, + operators: Array, + fields: Fields, + placeholder?: string, + operatorInfo?: OperatorInfo, + 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>; + +type FieldName = string | null; +export type FieldQuery = { fieldName: FieldName, operation: QueryOperation }; +export type FilterQuery = Array; + +const isRangeOperationValue = (input: unknown): input is RangeOperationValue => { + return Array.isArray(input) && input.length === 2 && typeof input[0] === 'number' && typeof input[1] === 'number'; +}; + +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') { + queryOperation = { $ne: value[0] }; + } else { + // Default to $eq + queryOperation = { $eq: value[0] }; + } + + return queryOperation; +}; + +const encodeArrayFieldQueryOperation = ( + operators: ArrayFieldOperator[], + value: Array | Primitive, + selectedOperator: ArrayFieldOperator, + selectedSubOperator: EnumFieldOperator | NumberFieldOperator | 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 (numberFieldOperators.includes(selectedSubOperator as NumberFieldOperator) && typeof value === 'string') { + // Remove comma and space from the value + const valueAsNumber = 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 (numberFieldOperators.includes(selectedSubOperator as NumberFieldOperator) && typeof value === 'string') { + // Remove comma and space from the value + const valueAsNumber = 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: string = '', + key: string, +): 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; + + if (isRangeOperationValue(value)) { + if (!value[0] || !value[1]) { + return null; + } + if (operators.includes('$range')) { + queryOperation = { $range: value }; + } else { + return null; + } + } 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 = 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 = []; + + if (operator === '$any' && '$any' in operation) { + if ('$or' in operation.$any && Array.isArray(operation.$any.$or)) { + operand = operation.$any.$or.map(value => { + if (typeof value !== 'object' || !value) { + return value; + } + + if ('$eq' in value) { + subOperatorSymbol = getOperatorLabel('$in', field.subfield); + return value.$eq; + } else { + return value; + } + }); + } else if ('$and' in operation.$any && Array.isArray(operation.$any.$and)) { + operand = operation.$any.$and.map(value => { + if (typeof value !== 'object' || !value) { + return value; + } + + if ('$ne' in value) { + subOperatorSymbol = getOperatorLabel('$nin', field.subfield); + return value.$ne; + } else { + return value; + } + }); + } else if (Object.keys(operation.$any)[0]) { + const subOperator = Object.keys(operation.$any)[0]; + + if (numberFieldOperators.includes(subOperator as NumberFieldOperator)) { + subOperatorSymbol = getOperatorLabel(subOperator as Operator, field.subfield); + operand = Object.values(operation.$any)[0]; + } + } + } else if (operator === '$all' && '$all' in operation) { + if ('$or' in operation.$all && Array.isArray(operation.$all.$or)) { + operand = operation.$all.$or.map(value => { + if (typeof value !== 'object' || !value) { + return value; + } + + if ('$eq' in value) { + subOperatorSymbol = getOperatorLabel('$in', field.subfield); + return value.$eq; + } else { + return value; + } + }); + } else if ('$and' in operation.$all && Array.isArray(operation.$all.$and)) { + operand = operation.$all.$and.map(value => { + if (typeof value !== 'object' || !value) { + return value; + } + + if ('$ne' in value) { + subOperatorSymbol = getOperatorLabel('$nin', field.subfield); + return value.$ne; + } else { + return value; + } + }); + } else if (Object.keys(operation.$all)[0]) { + const subOperator = Object.keys(operation.$all)[0]; + + if (numberFieldOperators.includes(subOperator as NumberFieldOperator)) { + subOperatorSymbol = getOperatorLabel(subOperator as Operator, field.subfield); + operand = Object.values(operation.$all)[0]; + } + } + } 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, + }; +}; + +const decodeFieldQuery = (fieldQuery: FieldQuery, fields: Fields): { + fieldName: FieldName, + operator: Operator, + operatorSymbol: string, + operand: any, + subOperatorSymbol?: string, +} => { + const field = fieldQuery.fieldName ? fields[fieldQuery.fieldName] : null; + const fieldType = fieldQuery.fieldName ? fields[fieldQuery.fieldName].type : null; + + if (fieldType === 'enum') { + return decodeEnumFieldQuery(fieldQuery); + } else if (field && field.type === 'array') { + return decodeArrayFieldQuery(fieldQuery, field); + } else if (fieldType === 'number') { + return decodeNumberFieldQuery(fieldQuery); + } else if (fieldType === 'datetime') { + return decodeDateTimeFieldQuery(fieldQuery); + } + + const operator = Object.keys(fieldQuery.operation)[0] as Operator; + const operatorSymbol = ':'; + const operationValue = Object.values(fieldQuery.operation)[0]; + const operand = operator === '$text' ? operationValue?.$search || '' : operationValue; + + return { + fieldName: fieldQuery.fieldName, + operator, + operatorSymbol, + operand, + }; +}; + +type UseFiltersProps = { + fields?: Fields, + customFilters?: FilterQuery, + query?: (filters: FilterQuery) => void; +}; +export const useFilters = (props: UseFiltersProps) => { + const { + fields, + customFilters, + query, + } = props; + + const [filters, setFilters] = React.useState([]); + + React.useEffect(() => { + setFilters(customFilters ?? []); + }, [customFilters]); + + const addFilter = (options: { + fieldName: FieldName, + value: Primitive | Array, + selectedOperator?: Operator | null, + selectedSubOperator?: Operator | null, + key?: string, + }) => { + const { + fieldName, + value, + selectedOperator = null, + selectedSubOperator = null, + key = '', + } = options; + + const fieldQuery = encodeFieldQuery(fieldName, value, selectedOperator, selectedSubOperator, fields, key); + + if (fieldName && fields && typeof fields[fieldName]?.onAddFilter === 'function') { + const field = fields[fieldName]; + 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: Fields, + filters?: FilterQuery, + onRemoveFilter?: (index: number) => void, + onRemoveAllFilters: () => void, +}; +export const Filters = (props: FiltersProps) => { + const { + fields, + filters = [], + onRemoveFilter, + onRemoveAllFilters, + } = props; + + const renderDateTimeFilter = (filter: FieldQuery, index: number) => { + const { fieldName, operatorSymbol, operand } = 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 (isRangeOperationValue(operand)) { + const startDateTime = dateFormat(operand[0] * 1000, 'MMMM do yyyy HH:mm'); + const endDateTime = dateFormat(operand[1] * 1000, 'MMMM do yyyy HH:mm'); + operandLabel = { from: startDateTime, to: endDateTime }; + } + } else { + const dateTime = dateFormat(operand * 1000, 'MMMM do yyyy HH:mm'); + operandLabel = dateTime; + } + } + + return ( + { onRemoveFilter?.(index); }} + primary + small + > + {fieldNameLabel + ? ( + <> + {`${fieldNameLabel}${symbol}`} + {typeof operandLabel === 'string' + ? {operandLabel} + : ( + <> + from + {operandLabel.from} + to + {operandLabel.to} + + ) + } + + ) : `${operandLabel}` + } + + ); + }; + + const renderArrayFilter = (filter: FieldQuery, index: number) => { + const { fieldName, operatorSymbol, operand, subOperatorSymbol = '' } = 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: string = ''; + + if (subField) { + if (subField.type === 'enum') { + if (Array.isArray(operand)) { + operandLabel = operand.map(o => subField?.alternatives[o]?.label || o).join(', '); + } else { + operandLabel = subField?.alternatives[operand]?.label || operand; + } + } else if (subField.type === 'number') { + operandLabel = operand; + } + } + + return ( + { onRemoveFilter?.(index); }} + primary + small + > + {fieldNameLabel + ? ( + <> + {`${fieldNameLabel}${symbol}`} + {operandLabel} + + ) : `${operandLabel}` + } + + ); + }; + + const renderFilter = (filter: FieldQuery, index: number) => { + const { fieldName, operatorSymbol, operand } = decodeFieldQuery(filter, fields); + const field = fieldName ? fields[fieldName] : null; + + if (field) { + if (field.type === 'datetime') { + return renderDateTimeFilter(filter, index); + } else 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; + + if (field && field.type === 'enum') { + if (Array.isArray(operand)) { + operandLabel = operand.map(o => field?.alternatives[o]?.label || o).join(', '); + } else { + operandLabel = field?.alternatives[operand]?.label || operand; + } + } else if (field && field.type === 'dictionary') { + operandLabel = Object.keys(operand).map(key => { + const keyLabel = field.suggestedKeys?.[key]?.label ?? key; + + return `${keyLabel} = ${operand[key]}`; + }).join(', '); + } else { + operandLabel = operand; + } + + return ( + { onRemoveFilter?.(index); }} + primary + small + > + {fieldNameLabel + ? ( + <> + {`${fieldNameLabel}${symbol}`} + {operandLabel} + + ) : `${operandLabel}` + } + + ); + }; + + const renderActions = () => { + return filters.length > 0 && ( +
+ + Clear all + +
+ ); + }; + + if (filters.length === 0) { + return null; + } + + return ( +
+
+ {filters.map(renderFilter)} +
+ {renderActions()} +
+ ); +}; + +type MultiSearchInputProps = ComponentPropsWithoutRef & { + fields: Fields, + fieldQueryBuffer: FieldQueryBuffer, + inputRef: React.RefObject, +}; +export const SearchInput = React.forwardRef((props, ref) => { + const { + className, + onKeyDown, + fields, + fieldQueryBuffer, + inputRef, + onFocus, + onBlur, + ...restProps + } = props; + + const combinedRefs = useCombinedRefs(ref, inputRef); + + 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 ( +
+ + {field && + + {field.label}{operator}{subOperator} {key ? `${key} =` : ''} + + } + +
+ ); +}); + +type FieldsDropdownProps = { + inputRef?: React.RefObject, + isActive?: boolean, + fields?: 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?: EnumFieldOperator[] | ArrayFieldOperator[], + alternatives?: Alternatives, + popperOptions?: Dropdown.PopperOptions, + selectedOperator: Operator, + onChange: (value: Primitive[]) => void, + onOutsideClick?: () => void, + validator?: 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 = (key?: string) => { + if (typeof key !== 'undefined') { + onChange([key]); + } + }; + + 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 = (event: React.ChangeEvent) => { + const target = event.target; + const checked = target.checked; + const value = target.value; + if (checked) { + setSelectedAlternatives([...selectedAlternatives, value]); + } else { + setSelectedAlternatives([...selectedAlternatives.filter(item => item !== value)]); + } + }; + + const renderMultiSelectAlternatives = () => ( + <> + + {Object.entries(alternatives || {}).map(([alternativesName, { label }]) => ( + + ))} + + <> + {!arrayValidation.isValid && arrayValidation.message && ( + + {arrayValidation.message} + + )} + +
+ +
+ + ); + + const renderAlternatives = () => ( + Object.entries(alternatives || {}).map(([alternativesName, { label }]) => ( + + {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, + minDate?: Date | number, + selectedDate?: SelectedDate, + canSelectDateTimeRange?: boolean, + validator?: DateTimeValidator, +}; + +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: 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 initDateTime = (selectedDate: SelectedDate | undefined, range: 'start' | 'end') => { + const defaultDate = setDate(new Date(), { seconds: 0, milliseconds: 0 }); + if (!selectedDate) { + return defaultDate; + } + + let date = defaultDate; + + if (isValidDateParamType(selectedDate)) { + date = getDateObject(selectedDate as number | Date); + } else if (isValidSelectedDate(selectedDate)) { + 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')); + + 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?: DictionaryFieldOperators[], + suggestedKeys?: SuggestedKeys, + 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 = (key?: string) => { + if (typeof key !== 'undefined') { + onChange(key); + } + }; + + 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 }]) => ( + + {label} + + )) + ); + + const renderSuggestedKeyInput = () => ( + + ); + + return ( + + {renderSuggestedKeys()} + {renderSuggestedKeyInput()} + + ); +}; + +type OperatorsDropdownProps = { + type: Field['type'], + inputRef: React.RefObject, + isActive: boolean, + operators: Array, + popperOptions?: Dropdown.PopperOptions, + onClick: (key?: NumberFieldOperator | DateTimeFieldOperator | EnumFieldOperator | ArrayFieldOperator) => void, + onOutsideClick?: () => void, + operatorInfo?: OperatorInfo, +}; + +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 Operator]?.label + : label; + }); + + return ( + + {ObjectUtil.entries(symbolMap) + .filter(entry => operators.includes(entry[0])) + .map(([operator, operatorSymbol]) => ( + + {operatorSymbol} + + )) + } + + ); +}; + +type FieldQueryBuffer = { + fieldName: FieldName, + operator: Operator | null, + subOperator: 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: Fields, + popperOptions?: Dropdown.PopperOptions, + query?: (filters: FilterQuery) => void; + filters?: FilterQuery, +}; +export const MultiSearch = (props: MultiSearchProps) => { + const inputRef = React.useRef(null); + + const { + className, + fields, + popperOptions: customPopperOptions, + query, + onFocus, + onClick, + disabled, + filters: customFilters, + } = 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): ValidatorResponse => { + let isValid = fieldQueryBuffer.value?.trim() !== ''; + let message = ''; + + if (fieldQueryBuffer.fieldName) { + const field: Field = fields[fieldQueryBuffer.fieldName]; + if (field.type === 'text') { + const searchInputValidator = field.validator as 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 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 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 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 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 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 && fieldQueryBuffer.fieldName) { + const field: 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 && operators.includes(fieldQueryBuffer.subOperator)) { + updateFieldQueryBuffer({ ...fieldQueryBuffer, subOperator: null }); + } else if (fieldQueryBuffer.operator && 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]; + + const newFieldQuery: FieldQueryBuffer = { + ...fieldQueryBuffer, + fieldName, + }; + + if (['number', 'datetime', 'enum', 'array'].includes(field.type) && field.operators.length === 1) { + newFieldQuery.operator = field.operators[0]; + } + + if (field.type === 'array' && field.subfield.operators.length === 1) { + 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 = ( + operator?: NumberFieldOperator | DateTimeFieldOperator | EnumFieldOperator | ArrayFieldOperator, + ) => { + if (typeof operator === 'undefined') { return; } + + const newFieldQuery = { ...fieldQueryBuffer, operator }; + + if (field.type === 'array' && field.subfield.operators.length === 1) { + 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 = ( + subOperator?: NumberFieldOperator | DateTimeFieldOperator | EnumFieldOperator | ArrayFieldOperator, + ) => { + if (typeof subOperator === 'undefined') { return; } + + updateFieldQueryBuffer({ ...fieldQueryBuffer, subOperator }); + + 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} + + )} + +
+ ); +}; +MultiSearch.displayName = 'MultiSearch'; diff --git a/src/components/tables/util/generateData.ts b/src/components/tables/util/generateData.ts new file mode 100644 index 00000000..2ac6c5a1 --- /dev/null +++ b/src/components/tables/util/generateData.ts @@ -0,0 +1,22 @@ + +import { seed, randFirstName, randLastName, randEmail, randCompanyName, randBetweenDate } from '@ngneat/falso'; + +export const generateData = ({ numItems = 10 } = {}) => { + seed('some-constant-seed'); // Use a fixed seed for consistent results + + const data = []; + + for (let i = 0; i < numItems; i += 1) { + const firstName = randFirstName(); + const lastName = randLastName(); + data.push({ + id: i, + name: `${firstName} ${lastName}`, + email: randEmail({ firstName, lastName }), + company: randCompanyName(), + joinDate: randBetweenDate({ from: new Date('01/01/2020'), to: new Date() }), + }); + } + + return data; +}; diff --git a/src/types/react-table.d.ts b/src/types/react-table.d.ts new file mode 100644 index 00000000..cc68fdc3 --- /dev/null +++ b/src/types/react-table.d.ts @@ -0,0 +1,159 @@ +/* eslint-disable @typescript-eslint/indent */ + +// See: +// https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/react-table + +import { + 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 * as MultiSearch from '../src/components/tables/MultiSearch/MultiSearch.tsx'; + +type FilterQuery = Array; // TEMP + +interface CustomColumnProps { + configurable?: boolean, + primary?: boolean, + label?: string, +} + +interface UseCustomFiltersState { + customFilters: FilterQuery; +} + +interface UseTableStreamState { + endOfStream: boolean, + partialStream: boolean, +} + +interface UseCustomFiltersInstanceProps { + setCustomFilters: (filters: 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?: object, + } + 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 {} +} From 04c6f29d74583fa35166e9af7b8373b44469ab16 Mon Sep 17 00:00:00 2001 From: mkrause Date: Tue, 26 Nov 2024 14:58:32 +0100 Subject: [PATCH 02/32] Add license headers. --- src/components/tables/DataTable/DataTableContext.tsx | 3 +++ src/components/tables/DataTable/DataTableEager.scss | 3 +++ src/components/tables/DataTable/DataTableEager.tsx | 3 +++ src/components/tables/DataTable/DataTableLazy.scss | 3 +++ src/components/tables/DataTable/DataTableLazy.tsx | 3 +++ src/components/tables/DataTable/DataTableStream.scss | 3 +++ src/components/tables/DataTable/DataTableStream.tsx | 3 +++ src/components/tables/DataTable/filtering/Filtering.ts | 4 ++++ src/components/tables/DataTable/pagination/Pagination.scss | 3 +++ src/components/tables/DataTable/pagination/Pagination.tsx | 3 +++ .../tables/DataTable/pagination/PaginationSizeSelector.scss | 3 +++ .../tables/DataTable/pagination/PaginationSizeSelector.tsx | 3 +++ .../tables/DataTable/pagination/PaginationStream.scss | 3 +++ .../tables/DataTable/pagination/PaginationStream.tsx | 3 +++ src/components/tables/DataTable/plugins/useCustomFilters.tsx | 3 +++ .../tables/DataTable/plugins/useRowSelectColumn.scss | 5 ++++- .../tables/DataTable/plugins/useRowSelectColumn.tsx | 3 +++ src/components/tables/DataTable/table/DataTable.scss | 3 +++ src/components/tables/DataTable/table/DataTable.tsx | 3 +++ .../tables/DataTable/table/DataTablePlaceholder.scss | 3 +++ .../tables/DataTable/table/DataTablePlaceholder.tsx | 3 +++ src/components/tables/MultiSearch/MultiSearch.scss | 3 +++ src/components/tables/MultiSearch/MultiSearch.stories.tsx | 3 +++ src/components/tables/MultiSearch/MultiSearch.tsx | 3 +++ src/components/tables/util/generateData.ts | 3 +++ 25 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/components/tables/DataTable/DataTableContext.tsx b/src/components/tables/DataTable/DataTableContext.tsx index 83a090a1..1308493f 100644 --- a/src/components/tables/DataTable/DataTableContext.tsx +++ b/src/components/tables/DataTable/DataTableContext.tsx @@ -1,3 +1,6 @@ +/* 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'; diff --git a/src/components/tables/DataTable/DataTableEager.scss b/src/components/tables/DataTable/DataTableEager.scss index e4c08872..660c6363 100644 --- a/src/components/tables/DataTable/DataTableEager.scss +++ b/src/components/tables/DataTable/DataTableEager.scss @@ -1,3 +1,6 @@ +/* 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 bkl; @use './DataTableLazy.scss' as dataTableLazy; diff --git a/src/components/tables/DataTable/DataTableEager.tsx b/src/components/tables/DataTable/DataTableEager.tsx index 096d8e72..912908fa 100644 --- a/src/components/tables/DataTable/DataTableEager.tsx +++ b/src/components/tables/DataTable/DataTableEager.tsx @@ -1,3 +1,6 @@ +/* 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, ClassNameArgument } from '../../../util/componentUtil.ts'; diff --git a/src/components/tables/DataTable/DataTableLazy.scss b/src/components/tables/DataTable/DataTableLazy.scss index 21bf3c09..6de6b2fd 100644 --- a/src/components/tables/DataTable/DataTableLazy.scss +++ b/src/components/tables/DataTable/DataTableLazy.scss @@ -1,3 +1,6 @@ +/* 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 bkl; @use '../../../style/mixins.scss' as mixins; diff --git a/src/components/tables/DataTable/DataTableLazy.tsx b/src/components/tables/DataTable/DataTableLazy.tsx index 0a4fc209..bb7e32bb 100644 --- a/src/components/tables/DataTable/DataTableLazy.tsx +++ b/src/components/tables/DataTable/DataTableLazy.tsx @@ -1,3 +1,6 @@ +/* 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, ClassNameArgument } from '../../../util/component_util'; diff --git a/src/components/tables/DataTable/DataTableStream.scss b/src/components/tables/DataTable/DataTableStream.scss index e48200ce..55eafbf4 100644 --- a/src/components/tables/DataTable/DataTableStream.scss +++ b/src/components/tables/DataTable/DataTableStream.scss @@ -1,3 +1,6 @@ +/* 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 bkl; @use './DataTableLazy.scss' as dataTableLazy; diff --git a/src/components/tables/DataTable/DataTableStream.tsx b/src/components/tables/DataTable/DataTableStream.tsx index 4ed1deae..0a9c0304 100644 --- a/src/components/tables/DataTable/DataTableStream.tsx +++ b/src/components/tables/DataTable/DataTableStream.tsx @@ -1,3 +1,6 @@ +/* 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/component_util'; diff --git a/src/components/tables/DataTable/filtering/Filtering.ts b/src/components/tables/DataTable/filtering/Filtering.ts index aada220d..817a8732 100644 --- a/src/components/tables/DataTable/filtering/Filtering.ts +++ b/src/components/tables/DataTable/filtering/Filtering.ts @@ -1,3 +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/. */ + import { getUnixTime } from 'date-fns'; import type { ArrayFieldSpec, diff --git a/src/components/tables/DataTable/pagination/Pagination.scss b/src/components/tables/DataTable/pagination/Pagination.scss index 1e0f1274..011aa53e 100644 --- a/src/components/tables/DataTable/pagination/Pagination.scss +++ b/src/components/tables/DataTable/pagination/Pagination.scss @@ -1,3 +1,6 @@ +/* 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 bkl; @use '../../../../style/mixins.scss' as mixins; diff --git a/src/components/tables/DataTable/pagination/Pagination.tsx b/src/components/tables/DataTable/pagination/Pagination.tsx index b08e7233..c7713389 100644 --- a/src/components/tables/DataTable/pagination/Pagination.tsx +++ b/src/components/tables/DataTable/pagination/Pagination.tsx @@ -1,3 +1,6 @@ +/* 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 cx from 'classnames'; import * as React from 'react'; diff --git a/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss b/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss index 0f46a980..d9876090 100644 --- a/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss +++ b/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss @@ -1,3 +1,6 @@ +/* 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 bkl; @use '../../../../style/mixins.scss' as mixins; diff --git a/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx b/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx index cbae7a67..877875cd 100644 --- a/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx +++ b/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx @@ -1,3 +1,6 @@ +/* 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 cx from 'classnames'; import * as React from 'react'; diff --git a/src/components/tables/DataTable/pagination/PaginationStream.scss b/src/components/tables/DataTable/pagination/PaginationStream.scss index 9abd0679..bf9ead04 100644 --- a/src/components/tables/DataTable/pagination/PaginationStream.scss +++ b/src/components/tables/DataTable/pagination/PaginationStream.scss @@ -1,3 +1,6 @@ +/* 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 bkl; @use '../../../../style/mixins.scss' as mixins; diff --git a/src/components/tables/DataTable/pagination/PaginationStream.tsx b/src/components/tables/DataTable/pagination/PaginationStream.tsx index 9739da3b..d1fbd004 100644 --- a/src/components/tables/DataTable/pagination/PaginationStream.tsx +++ b/src/components/tables/DataTable/pagination/PaginationStream.tsx @@ -1,3 +1,6 @@ +/* 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 cx from 'classnames'; import * as React from 'react'; diff --git a/src/components/tables/DataTable/plugins/useCustomFilters.tsx b/src/components/tables/DataTable/plugins/useCustomFilters.tsx index 03952954..cad52639 100644 --- a/src/components/tables/DataTable/plugins/useCustomFilters.tsx +++ b/src/components/tables/DataTable/plugins/useCustomFilters.tsx @@ -1,3 +1,6 @@ +/* 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'; diff --git a/src/components/tables/DataTable/plugins/useRowSelectColumn.scss b/src/components/tables/DataTable/plugins/useRowSelectColumn.scss index 97af459b..a132752c 100644 --- a/src/components/tables/DataTable/plugins/useRowSelectColumn.scss +++ b/src/components/tables/DataTable/plugins/useRowSelectColumn.scss @@ -1,3 +1,6 @@ +/* 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 bkl; @@ -12,4 +15,4 @@ &__cell { margin-bottom: bkl.$sizing-m; } -} \ No newline at end of file +} diff --git a/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx b/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx index 9fca78fc..e0c05780 100644 --- a/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx +++ b/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx @@ -1,3 +1,6 @@ +/* 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'; diff --git a/src/components/tables/DataTable/table/DataTable.scss b/src/components/tables/DataTable/table/DataTable.scss index 7c13685c..a8307cbd 100644 --- a/src/components/tables/DataTable/table/DataTable.scss +++ b/src/components/tables/DataTable/table/DataTable.scss @@ -1,3 +1,6 @@ +/* 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 bkl; @use '../../../../style/mixins.scss' as mixins; diff --git a/src/components/tables/DataTable/table/DataTable.tsx b/src/components/tables/DataTable/table/DataTable.tsx index d175ef6f..f8f0e80e 100644 --- a/src/components/tables/DataTable/table/DataTable.tsx +++ b/src/components/tables/DataTable/table/DataTable.tsx @@ -1,3 +1,6 @@ +/* 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, type ComponentProps } from '../../../../util/componentUtil.ts'; diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.scss b/src/components/tables/DataTable/table/DataTablePlaceholder.scss index e1315386..76e7a622 100644 --- a/src/components/tables/DataTable/table/DataTablePlaceholder.scss +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.scss @@ -1,3 +1,6 @@ +/* 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 bkl; @use '../../../../style/mixins.scss' as mixins; diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.tsx b/src/components/tables/DataTable/table/DataTablePlaceholder.tsx index 3c549680..d78bb3f3 100644 --- a/src/components/tables/DataTable/table/DataTablePlaceholder.tsx +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.tsx @@ -1,3 +1,6 @@ +/* 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, type ComponentProps } from '../../../../util/componentUtil.ts'; diff --git a/src/components/tables/MultiSearch/MultiSearch.scss b/src/components/tables/MultiSearch/MultiSearch.scss index 87bc455c..1ba904d2 100644 --- a/src/components/tables/MultiSearch/MultiSearch.scss +++ b/src/components/tables/MultiSearch/MultiSearch.scss @@ -1,3 +1,6 @@ +/* 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'; diff --git a/src/components/tables/MultiSearch/MultiSearch.stories.tsx b/src/components/tables/MultiSearch/MultiSearch.stories.tsx index 506296b6..1fa02cc9 100644 --- a/src/components/tables/MultiSearch/MultiSearch.stories.tsx +++ b/src/components/tables/MultiSearch/MultiSearch.stories.tsx @@ -1,3 +1,6 @@ +/* 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'; diff --git a/src/components/tables/MultiSearch/MultiSearch.tsx b/src/components/tables/MultiSearch/MultiSearch.tsx index ca4e00b2..c2f8c66c 100644 --- a/src/components/tables/MultiSearch/MultiSearch.tsx +++ b/src/components/tables/MultiSearch/MultiSearch.tsx @@ -1,3 +1,6 @@ +/* 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'; import * as ObjectUtil from '../../../util/object_util'; diff --git a/src/components/tables/util/generateData.ts b/src/components/tables/util/generateData.ts index 2ac6c5a1..96d4ecab 100644 --- a/src/components/tables/util/generateData.ts +++ b/src/components/tables/util/generateData.ts @@ -1,3 +1,6 @@ +/* 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 } from '@ngneat/falso'; From 7060de871bc0aa8904b7a83f774da155f9e531cd Mon Sep 17 00:00:00 2001 From: mkrause Date: Wed, 27 Nov 2024 13:54:07 +0100 Subject: [PATCH 03/32] Rework basic DataTable components to be free of errors. --- src/assets/icons/_icons.ts | 7 ---- src/components/graphics/Icon/Icon.tsx | 7 +++- .../DataTable/plugins/useCustomFilters.tsx | 9 +++--- .../DataTable/plugins/useRowSelectColumn.scss | 8 ++--- .../DataTable/plugins/useRowSelectColumn.tsx | 7 ++-- .../tables/DataTable/table/DataTable.tsx | 4 +-- .../DataTable/table/DataTablePlaceholder.tsx | 32 ++++++------------- 7 files changed, 29 insertions(+), 45 deletions(-) diff --git a/src/assets/icons/_icons.ts b/src/assets/icons/_icons.ts index 323597b4..61491339 100644 --- a/src/assets/icons/_icons.ts +++ b/src/assets/icons/_icons.ts @@ -56,10 +56,3 @@ export const icons = { 'warning': {}, 'workflows': {}, } as const satisfies Record; - -export type IconKey = keyof typeof icons; - -const iconKeys = new Set(Object.keys(icons)); -export const isIconKey = (iconKey: string): iconKey is IconKey => { - return iconKeys.has(iconKey); -}; 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/plugins/useCustomFilters.tsx b/src/components/tables/DataTable/plugins/useCustomFilters.tsx index cad52639..c74c5274 100644 --- a/src/components/tables/DataTable/plugins/useCustomFilters.tsx +++ b/src/components/tables/DataTable/plugins/useCustomFilters.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import * as ReactTable from 'react-table'; +import { type FilterQuery } from '../../MultiSearch/MultiSearch.tsx'; // Actions @@ -26,13 +27,11 @@ const reducer = ( }; const useInstance = (instance: ReactTable.TableInstance) => { - const { - state: { customFilters }, - dispatch, - } = instance; + const { dispatch, } = instance; + //const customFilters = instance.state.customFilters; const setCustomFilters = React.useCallback( - customFilters => { + (customFilters: FilterQuery) => { return dispatch({ type: ReactTable.actions.setCustomFilters, customFilters }); }, [dispatch], diff --git a/src/components/tables/DataTable/plugins/useRowSelectColumn.scss b/src/components/tables/DataTable/plugins/useRowSelectColumn.scss index a132752c..ef3aec8b 100644 --- a/src/components/tables/DataTable/plugins/useRowSelectColumn.scss +++ b/src/components/tables/DataTable/plugins/useRowSelectColumn.scss @@ -7,12 +7,12 @@ .bkl-data-table-row-select { width: bkl.$sizing-7 + bkl.$sizing-1; max-width: bkl.$sizing-7 + bkl.$sizing-1; - - &__header { + + .bkl-data-table-row-select__header { margin-bottom: bkl.$sizing-m; } - - &__cell { + + .bkl-data-table-row-select__cell { margin-bottom: bkl.$sizing-m; } } diff --git a/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx b/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx index e0c05780..be78f0f0 100644 --- a/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx +++ b/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx @@ -4,7 +4,8 @@ import * as React from 'react'; import * as ReactTable from 'react-table'; -import { Checkbox } from '../../../forms/checkbox/Checkbox'; + +import { Checkbox } from '../../../forms/controls/Checkbox/Checkbox.tsx'; import './useRowSelectColumn.scss'; @@ -20,7 +21,7 @@ export const useRowSelectColumn = (hooks: ReactTable.Hooks) const { checked, onChange } = getToggleAllPageRowsSelectedProps(); return (
- +
); }, @@ -28,7 +29,7 @@ export const useRowSelectColumn = (hooks: ReactTable.Hooks) const { checked, onChange } = row.getToggleRowSelectedProps(); return (
- +
); }, diff --git a/src/components/tables/DataTable/table/DataTable.tsx b/src/components/tables/DataTable/table/DataTable.tsx index f8f0e80e..aedb75d4 100644 --- a/src/components/tables/DataTable/table/DataTable.tsx +++ b/src/components/tables/DataTable/table/DataTable.tsx @@ -19,7 +19,7 @@ import type { DataTableStatus } from '../DataTableContext.tsx'; // Note: `placeholder` is included in `table` props as part of "Standard HTML Attributes", but it's not actually a -// valid `` attribute, so we can override it. +// valid `
` attribute, so we can safely override it. type DataTableProps = Omit, 'placeholder'> & { table: ReactTable.TableInstance, columnGroups?: React.ReactNode, @@ -76,7 +76,7 @@ export const DataTable = (props: DataTableProps) => { {column.render('Header')} {column.canSort && - & { - icon?: IconKey | React.ReactNode, + icon?: IconName | React.ReactNode, classNameIcon?: ClassNameArgument, classNameMessage?: ClassNameArgument, classNameActions?: ClassNameArgument, @@ -29,16 +28,10 @@ export const DataTablePlaceholder = (props: DataTablePlaceholderProps) => { }; const renderIcon = (): React.ReactNode => { - if (typeof icon === 'undefined') { - return renderStandardIcon('view-type-table'); + if (typeof icon === 'string' && isIconName(icon)) { + return renderStandardIcon(icon); } - if (typeof icon === 'string') { - if (isIconKey(icon)) { - return renderStandardIcon(icon); - } - throw new Error(`Invalid icon ${icon}`); - } - return icon; + return renderStandardIcon('file'); }; return ( @@ -57,7 +50,6 @@ export const DataTablePlaceholder = (props: DataTablePlaceholderProps) => { ); }; -DataTablePlaceholder.displayName = 'DataTablePlaceholder'; // Loading skeleton (when there's no data to show yet) @@ -72,7 +64,6 @@ export const DataTablePlaceholderSkeleton = (props: DataTablePlaceholderSkeleton ); }; -DataTablePlaceholderSkeleton.displayName = 'DataTablePlaceholderSkeleton'; // Empty table (ready but no data) @@ -89,7 +80,6 @@ export const DataTablePlaceholderEmpty = (props: DataTablePlaceholderEmptyProps) /> ); }; -DataTablePlaceholderEmpty.displayName = 'DataTablePlaceholderEmpty'; type DataTableErrorIconProps = Omit & { @@ -100,14 +90,13 @@ export const DataTableErrorIcon = (props: DataTableErrorIconProps) => { return (
-
); }; -DataTableErrorIcon.displayName = 'DataTableErrorIcon'; type DataTablePlaceholderErrorProps = Omit & { // Make `placeholderMessage` optional @@ -123,11 +112,10 @@ export const DataTablePlaceholderError = (props: DataTablePlaceholderErrorProps) /> ); }; -DataTablePlaceholderError.displayName = 'DataTablePlaceholderError'; type DataTableRowPlaceholderProps = ComponentProps<'div'> & { - icon?: IconKey | React.ReactNode, + icon?: IconName | React.ReactNode, classNameIcon?: ClassNameArgument, classNameMessage?: ClassNameArgument, classNameActions?: ClassNameArgument, @@ -145,10 +133,10 @@ export const DataTableRowPlaceholder = (props: DataTableRowPlaceholderProps) => const renderIcon = (): React.ReactNode => { if (typeof icon === 'undefined') { - return renderStandardIcon('event-warning'); + return renderStandardIcon('alert'); } if (typeof icon === 'string') { - if (isIconKey(icon)) { + if (isIconName(icon)) { return renderStandardIcon(icon); } throw new Error(`Invalid icon ${icon}`); @@ -172,7 +160,6 @@ export const DataTableRowPlaceholder = (props: DataTableRowPlaceholderProps) => ); }; -DataTableRowPlaceholder.displayName = 'DataTableRowPlaceholder'; type DataTablePlaceholderEndOfTableProps = Omit & { // Make `placeholderMessage` optional @@ -188,4 +175,3 @@ export const DataTablePlaceholderEndOfTable = (props: DataTablePlaceholderEndOfT /> ); }; -DataTablePlaceholderEndOfTable.displayName = 'DataTablePlaceholderEndOfTable'; From 67da159048b53a2e9c230e90112d59bebf5f127b Mon Sep 17 00:00:00 2001 From: mkrause Date: Thu, 28 Nov 2024 17:11:41 +0100 Subject: [PATCH 04/32] Work on MultiSearch component. --- biome.jsonc | 6 +- package.json | 3 +- package.json.js | 6 +- scripts/import.ts | 1 - .../fields/CheckboxField/CheckboxField.tsx | 28 +- .../fields/CheckboxGroup/CheckboxGroup.tsx | 8 +- .../tables/DataTable/table/DataTable.tsx | 2 - .../tables/MultiSearch/MultiSearch.tsx | 1200 ++++------------- .../tables/MultiSearch/filterQuery.ts | 647 +++++++++ src/components/text/Tag/Tag.tsx | 4 +- src/util/hooks/useFocus.ts | 45 + src/util/hooks/useOutsideClickHandler.ts | 33 + src/util/objectUtil.test.ts | 304 +++++ src/util/objectUtil.ts | 146 ++ src/util/random.ts | 6 + 15 files changed, 1489 insertions(+), 950 deletions(-) create mode 100644 src/components/tables/MultiSearch/filterQuery.ts create mode 100644 src/util/hooks/useFocus.ts create mode 100644 src/util/hooks/useOutsideClickHandler.ts create mode 100644 src/util/objectUtil.test.ts create mode 100644 src/util/objectUtil.ts create mode 100644 src/util/random.ts diff --git a/biome.jsonc b/biome.jsonc index 255afc92..7c9566c9 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -24,6 +24,9 @@ "linter": { "enabled": true, "include": ["app/**/*", "src/**/*", "tests/**/*"], + "ignore": [ + "src/components/tables/MultiSearch/MultiSearch.tsx" // Ignore for now (need to focus on type errors first) + ], "rules": { "recommended": true, "complexity": { @@ -32,7 +35,8 @@ }, "style": { "useImportType": "off", - "noUnusedTemplateLiteral": "off" + "noUnusedTemplateLiteral": "off", + "noUselessElse": "off" } } } diff --git a/package.json b/package.json index ac856c1c..4d7cee12 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "lint:style": "stylelint 'src/**/*.scss'", "lint:script": "biome lint", "lint": "npm run lint:style && npm run lint:script", - "test": "npm run check:types && npm run lint:style", + "test:unit": "vitest run --root=.", + "test": "npm run check:types; npm run lint:style; npm run test:unit", "test-ui": "vitest --ui", "coverage": "vitest run --coverage", "start": "npm run storybook:serve", diff --git a/package.json.js b/package.json.js index c8fddf06..84c49e0f 100644 --- a/package.json.js +++ b/package.json.js @@ -64,12 +64,12 @@ const packageConfig = { 'check:types': 'tsc --noEmit', '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 - //'test': 'vitest run --root=.', // Need to specify `--root=.` since the vite root is set to `./app` - 'test': 'npm run check:types && npm run lint:style', + 'test:unit': 'vitest run --root=.', // Need to specify `--root=.` since the vite root is set to `./app` + 'test': 'npm run check:types; npm run lint:style; npm run test:unit', 'test-ui': 'vitest --ui', 'coverage': 'vitest run --coverage', diff --git a/scripts/import.ts b/scripts/import.ts index 31645dc7..57abaaea 100755 --- a/scripts/import.ts +++ b/scripts/import.ts @@ -141,7 +141,6 @@ const runImportIcons = async (args: ScriptArgs) => { 'page-fwd': 'page-forward', 'user-account': 'user-profile', // "User account" is a misleading term considering our information architecture 'users': 'user', - // NOTE: missing `account` icon }; const pathIcons = path.join(process.cwd(), './src/assets/icons_new'); diff --git a/src/components/forms/fields/CheckboxField/CheckboxField.tsx b/src/components/forms/fields/CheckboxField/CheckboxField.tsx index 3b8b49c7..7790dc3d 100644 --- a/src/components/forms/fields/CheckboxField/CheckboxField.tsx +++ b/src/components/forms/fields/CheckboxField/CheckboxField.tsx @@ -42,36 +42,36 @@ 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, - + /** A label to be displayed after the checkbox. */ label: string, - + /** An optional supporting copy to be displayed under the label. */ sublabel?: undefined | string, - + /** An optional title. */ title?: undefined | string, - + /** An optional tooltip to be displayed on an info icon next to the title. */ titleTooltip?: undefined | string, - + /** Whether to display the optional observation on title. */ titleOptional?: undefined | boolean, - + /** Whether the checkbox is checked by default. Passed down to Checkbox component. */ defaultChecked?: undefined | boolean, - + /** Whether the checkbox is checked. Passed down to Checkbox component. */ checked?: undefined | boolean, - + /** Whether the checkbox is disabled. Passed down to Checkbox component. */ 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,8 +86,9 @@ export const CheckboxField = (props: CheckboxFieldProps) => { titleOptional, titleTooltip, className, + ...propsRest } = props; - + return (
{ {title} )} - {/* biome ignore lint/a11y/noLabelWithoutControl: the `` will resolve to an `` */} + {/* biome-ignore lint/a11y/noLabelWithoutControl: the `` will resolve to an `` */}
); }; -DataTable.displayName = 'DataTable'; type DataTableSyncProps = DataTableProps & { @@ -244,4 +243,3 @@ export const DataTableAsync = (props: DataTableAsyncProps)
); }; -DataTableAsync.displayName = 'DataTableAsync'; diff --git a/src/components/tables/MultiSearch/MultiSearch.tsx b/src/components/tables/MultiSearch/MultiSearch.tsx index c2f8c66c..8bc608c1 100644 --- a/src/components/tables/MultiSearch/MultiSearch.tsx +++ b/src/components/tables/MultiSearch/MultiSearch.tsx @@ -2,8 +2,8 @@ |* 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'; -import * as ObjectUtil from '../../../util/object_util'; +import * as Random from '../../../util/random.ts'; +import * as ObjectUtil from '../../../util/objectUtil.ts'; import { isEqual, fromUnixTime, @@ -17,167 +17,32 @@ import { import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { classNames as cx, ClassNameArgument, ComponentPropsWithoutRef } from '../../../util/component_util'; +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 { useOutsideClickHandler } from '../../../util/hooks/useOutsideClickHandler'; +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 { DateTimePicker } from '../../forms/datetime/DateTimePicker.tsx'; -import { BaklavaIcon } from '../../../components/icons/icon-pack-baklava/BaklavaIcon'; -import Input from '../../../components/forms/input/Input'; -import * as Dropdown from '../../../components/overlays/dropdown/Dropdown'; -import { Tag } from '../../../components/containers/tag/Tag'; -import { useCombinedRefs } from '../../../util/hooks/useCombinedRefs'; -import { useFocus } from '../../../util/hooks/useFocus'; -import Checkbox from '../../../components/forms/checkbox/Checkbox'; -import { Button } from '../../../components/buttons/Button'; -import { DateTimePicker } from '../../../components/forms/datetime/DateTimePicker'; -import { Caption } from '../../../components/typography/caption/Caption'; +import * as FQ from './filterQuery.ts'; -import './MultiSearch.scss'; +//import './MultiSearch.scss'; -// Suggestions dropdown -const SuggestionItem = Dropdown.Item; - -export type SuggestionProps = Omit, 'children'> & { - children: React.ReactNode | ((props: { close: () => void }) => React.ReactNode), - elementRef?: React.RefObject, // Helps to toggle multiple dropdowns on the same reference element - active?: boolean, - withArrow?: boolean, - primary?: boolean, - secondary?: boolean, - basic?: boolean, - popperOptions?: Dropdown.PopperOptions, - onOutsideClick?: () => void, - containerRef?: 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)} - - ); -}; - -// Utility -type ValueOf> = T[number]; - -// Operators -const enumFieldOperators = ['$in', '$nin', '$eq', '$ne'] as const; -type EnumFieldOperator = ValueOf; - -const arrayFieldOperators = ['$eq', '$ne', '$all', '$any'] as const; -type ArrayFieldOperator = ValueOf; - -const textFieldOperators = ['$eq', '$text'] as const; -type TextFieldOperator = ValueOf; - -const numberFieldOperators = ['$eq', '$gt', '$gte', '$lt', '$lte', '$ne'] as const; -type NumberFieldOperator = ValueOf; - -const dictionaryFieldOperators = ['$all'] as const; -type DictionaryFieldOperators = ValueOf; - -const recordFieldOperators = ['$all', '$any'] as const; -type RecordFieldOperators = ValueOf; +// Utilities +type Primitive = null | string | number | bigint | boolean; -const dateTimeFieldOperators = ['$eq', '$gt', '$gte', '$lt', '$lte', '$ne', '$range'] as const; -type DateTimeFieldOperator = ValueOf; -const operators = [ - ...enumFieldOperators, - ...arrayFieldOperators, - ...textFieldOperators, - ...numberFieldOperators, - ...dictionaryFieldOperators, - ...dateTimeFieldOperators, -] as const; -type Operator = ValueOf; +// Map operators to a human-readable string to be shown in the UI -const numberOperatorsToSymbolMap: Record = { +const numberOperatorsToSymbolMap: Record = { '$eq': '\u003D', '$lt': '\u003C', '$lte': '\u2264', @@ -186,7 +51,7 @@ const numberOperatorsToSymbolMap: Record = { '$ne': '\u2260', } as const; -const dateTimeFieldOperatorsToSymbolMap: Record = { +const dateTimeFieldOperatorsToSymbolMap: Record = { '$eq': '\u003D', '$lt': '\u003C', '$lte': '\u2264', @@ -196,669 +61,68 @@ const dateTimeFieldOperatorsToSymbolMap: Record = '$range': 'Range', } as const; -const enumOperatorsToSymbolMap: Record = { +const enumOperatorsToSymbolMap: Record = { '$eq': 'is', '$ne': 'is not', '$in': 'is one of', '$nin': 'is none of', }; -const arrayOperatorsToSymbolMap: Record = { +const arrayOperatorsToSymbolMap: Record = { '$eq': 'is', '$ne': 'is not', '$any': 'contains any matching', '$all': 'contains all matching', }; -const getOperatorLabel = (operator: Operator, field: Field) => { +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') { - if (operator in arrayOperatorsToSymbolMap) { - label = arrayOperatorsToSymbolMap[operator as ArrayFieldOperator]; - } + label = arrayOperatorsToSymbolMap[operator as FQ.ArrayFieldOperator] ?? ''; } else if (field.type === 'enum') { - if (operator in enumOperatorsToSymbolMap) { - label = enumOperatorsToSymbolMap[operator as EnumFieldOperator]; - } + label = enumOperatorsToSymbolMap[operator as FQ.EnumFieldOperator] ?? ''; } else if (field.type === 'number') { - if (operator in numberOperatorsToSymbolMap) { - label = numberOperatorsToSymbolMap[operator as NumberFieldOperator]; - } + label = numberOperatorsToSymbolMap[operator as FQ.NumberFieldOperator] ?? ''; } else if (field.type === 'datetime') { - if (operator in dateTimeFieldOperatorsToSymbolMap) { - label = dateTimeFieldOperatorsToSymbolMap[operator as DateTimeFieldOperator]; - } - } - - return label; -}; - -// Field specification -type Alternative = { label: string }; -type Alternatives = Record; -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 -}; - -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: any) => R; - -export type EnumFieldSpec = { - type: 'enum', - label: React.ReactNode, - operators: Array, - alternatives: Alternatives, - placeholder?: string, - operatorInfo?: OperatorInfo, - validator?: EnumValidator, - accessor?: Accessor, -}; -export type ArrayFieldSpec = { - type: 'array', - label: React.ReactNode, - operators: Array, - subfield: EnumFieldSpec | NumberFieldSpec, - placeholder?: string, - operatorInfo?: OperatorInfo, - validator?: ArrayValidator, - accessor?: Accessor, -}; -export type TextFieldSpec = { - type: 'text', - label: React.ReactNode, - operators: Array, - placeholder?: string, - operatorInfo?: OperatorInfo, - validator?: TextValidator, - accessor?: Accessor, -}; -export type NumberFieldSpec = { - type: 'number', - label: React.ReactNode, - operators: Array, - placeholder?: string, - operatorInfo?: OperatorInfo, - validator?: TextValidator, - accessor?: Accessor, -}; - -type DateType = Date | number; -export type SelectedDate = DateType | [DateType, DateType]; -export type OnAddFilter = (newFilter: FieldQuery, currentFilters: FilterQuery) => FilterQuery; - -export type DateTimeFieldSpec = { - type: 'datetime', - label: React.ReactNode, - operators: Array, - placeholder?: string, - selectedDate?: SelectedDate, - onAddFilter?: OnAddFilter, - maxDate?: Date | number, - minDate?: Date | number, - operatorInfo?: OperatorInfo, - validator?: DateTimeValidator, - accessor?: Accessor, -}; -type SuggestedKey = { label: string }; -type SuggestedKeys = { [key: string]: SuggestedKey }; -export type DictionaryFieldSpec = { - type: 'dictionary', - label: React.ReactNode, - operators: Array, - suggestedKeys?: SuggestedKeys, - placeholder?: string, - operatorInfo?: OperatorInfo, - validator?: DictionaryValidator, - accessor?: Accessor>, -}; -export type RecordFieldSpec = { - type: 'record', - label: React.ReactNode, - operators: Array, - fields: Fields, - placeholder?: string, - operatorInfo?: OperatorInfo, - 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>; - -type FieldName = string | null; -export type FieldQuery = { fieldName: FieldName, operation: QueryOperation }; -export type FilterQuery = Array; - -const isRangeOperationValue = (input: unknown): input is RangeOperationValue => { - return Array.isArray(input) && input.length === 2 && typeof input[0] === 'number' && typeof input[1] === 'number'; -}; - -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') { - queryOperation = { $ne: value[0] }; - } else { - // Default to $eq - queryOperation = { $eq: value[0] }; - } - - return queryOperation; -}; - -const encodeArrayFieldQueryOperation = ( - operators: ArrayFieldOperator[], - value: Array | Primitive, - selectedOperator: ArrayFieldOperator, - selectedSubOperator: EnumFieldOperator | NumberFieldOperator | 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 (numberFieldOperators.includes(selectedSubOperator as NumberFieldOperator) && typeof value === 'string') { - // Remove comma and space from the value - const valueAsNumber = 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 (numberFieldOperators.includes(selectedSubOperator as NumberFieldOperator) && typeof value === 'string') { - // Remove comma and space from the value - const valueAsNumber = 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: string = '', - key: string, -): 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; - - if (isRangeOperationValue(value)) { - if (!value[0] || !value[1]) { - return null; - } - if (operators.includes('$range')) { - queryOperation = { $range: value }; - } else { - return null; - } - } 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 = 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 = []; - - if (operator === '$any' && '$any' in operation) { - if ('$or' in operation.$any && Array.isArray(operation.$any.$or)) { - operand = operation.$any.$or.map(value => { - if (typeof value !== 'object' || !value) { - return value; - } - - if ('$eq' in value) { - subOperatorSymbol = getOperatorLabel('$in', field.subfield); - return value.$eq; - } else { - return value; - } - }); - } else if ('$and' in operation.$any && Array.isArray(operation.$any.$and)) { - operand = operation.$any.$and.map(value => { - if (typeof value !== 'object' || !value) { - return value; - } - - if ('$ne' in value) { - subOperatorSymbol = getOperatorLabel('$nin', field.subfield); - return value.$ne; - } else { - return value; - } - }); - } else if (Object.keys(operation.$any)[0]) { - const subOperator = Object.keys(operation.$any)[0]; - - if (numberFieldOperators.includes(subOperator as NumberFieldOperator)) { - subOperatorSymbol = getOperatorLabel(subOperator as Operator, field.subfield); - operand = Object.values(operation.$any)[0]; - } - } - } else if (operator === '$all' && '$all' in operation) { - if ('$or' in operation.$all && Array.isArray(operation.$all.$or)) { - operand = operation.$all.$or.map(value => { - if (typeof value !== 'object' || !value) { - return value; - } - - if ('$eq' in value) { - subOperatorSymbol = getOperatorLabel('$in', field.subfield); - return value.$eq; - } else { - return value; - } - }); - } else if ('$and' in operation.$all && Array.isArray(operation.$all.$and)) { - operand = operation.$all.$and.map(value => { - if (typeof value !== 'object' || !value) { - return value; - } - - if ('$ne' in value) { - subOperatorSymbol = getOperatorLabel('$nin', field.subfield); - return value.$ne; - } else { - return value; - } - }); - } else if (Object.keys(operation.$all)[0]) { - const subOperator = Object.keys(operation.$all)[0]; - - if (numberFieldOperators.includes(subOperator as NumberFieldOperator)) { - subOperatorSymbol = getOperatorLabel(subOperator as Operator, field.subfield); - operand = Object.values(operation.$all)[0]; - } - } - } else { - operand = Object.values(fieldQuery.operation)[0]; + label = dateTimeFieldOperatorsToSymbolMap[operator as FQ.DateTimeFieldOperator] ?? ''; } - 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, - }; + return label; }; -const decodeFieldQuery = (fieldQuery: FieldQuery, fields: Fields): { - fieldName: FieldName, - operator: Operator, - operatorSymbol: string, - operand: any, - subOperatorSymbol?: string, -} => { - const field = fieldQuery.fieldName ? fields[fieldQuery.fieldName] : null; - const fieldType = fieldQuery.fieldName ? fields[fieldQuery.fieldName].type : null; - - if (fieldType === 'enum') { - return decodeEnumFieldQuery(fieldQuery); - } else if (field && field.type === 'array') { - return decodeArrayFieldQuery(fieldQuery, field); - } else if (fieldType === 'number') { - return decodeNumberFieldQuery(fieldQuery); - } else if (fieldType === 'datetime') { - return decodeDateTimeFieldQuery(fieldQuery); - } - const operator = Object.keys(fieldQuery.operation)[0] as Operator; - const operatorSymbol = ':'; - const operationValue = Object.values(fieldQuery.operation)[0]; - const operand = operator === '$text' ? operationValue?.$search || '' : operationValue; - - return { - fieldName: fieldQuery.fieldName, - operator, - operatorSymbol, - operand, - }; -}; +// +// Query filter management +// type UseFiltersProps = { - fields?: Fields, - customFilters?: FilterQuery, - query?: (filters: FilterQuery) => void; + 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 }; -export const useFilters = (props: UseFiltersProps) => { +// Custom hook to manage a `FilterQuery` instance +const useFilters = (props: UseFiltersProps) => { const { fields, customFilters, query, } = props; - const [filters, setFilters] = React.useState([]); + const [filters, setFilters] = React.useState([]); React.useEffect(() => { setFilters(customFilters ?? []); }, [customFilters]); - + const addFilter = (options: { - fieldName: FieldName, + fieldName: FQ.FieldName, value: Primitive | Array, - selectedOperator?: Operator | null, - selectedSubOperator?: Operator | null, - key?: string, + selectedOperator?: undefined | null | FQ.Operator, + selectedSubOperator?: undefined | null | FQ.Operator, + key?: undefined | string, }) => { const { fieldName, @@ -867,8 +131,8 @@ export const useFilters = (props: UseFiltersProps) => { selectedSubOperator = null, key = '', } = options; - - const fieldQuery = encodeFieldQuery(fieldName, value, selectedOperator, selectedSubOperator, fields, key); + + const fieldQuery = FQ.encodeFieldQuery(fieldName, value, selectedOperator, selectedSubOperator, fields, key); if (fieldName && fields && typeof fields[fieldName]?.onAddFilter === 'function') { const field = fields[fieldName]; @@ -879,12 +143,12 @@ export const useFilters = (props: UseFiltersProps) => { query?.(newFilters); } }; - + const removeFilter = (index: number) => { const newFilters = filters.filter((_, i) => i !== index); query?.(newFilters); }; - + const removeAllFilters = () => { query?.([]); }; @@ -899,8 +163,8 @@ export const useFilters = (props: UseFiltersProps) => { }; type FiltersProps = { - fields: Fields, - filters?: FilterQuery, + fields: FQ.Fields, + filters?: FQ.FilterQuery, onRemoveFilter?: (index: number) => void, onRemoveAllFilters: () => void, }; @@ -911,7 +175,7 @@ export const Filters = (props: FiltersProps) => { onRemoveFilter, onRemoveAllFilters, } = props; - + const renderDateTimeFilter = (filter: FieldQuery, index: number) => { const { fieldName, operatorSymbol, operand } = decodeFieldQuery(filter, fields); const field = fieldName ? fields[fieldName] : null; @@ -940,38 +204,35 @@ export const Filters = (props: FiltersProps) => { operandLabel = dateTime; } } - + + const content: React.ReactNode = fieldNameLabel + ? ( + <> + {`${fieldNameLabel}${symbol}`} + {typeof operandLabel === 'string' + ? {operandLabel} + : ( + <> + from + {operandLabel.from} + to + {operandLabel.to} + + ) + } + + ) : `${operandLabel}`; + return ( { onRemoveFilter?.(index); }} - primary - small - > - {fieldNameLabel - ? ( - <> - {`${fieldNameLabel}${symbol}`} - {typeof operandLabel === 'string' - ? {operandLabel} - : ( - <> - from - {operandLabel.from} - to - {operandLabel.to} - - ) - } - - ) : `${operandLabel}` - } - + onRemove={() => { onRemoveFilter?.(index); }} + content={content} + /> ); }; - - const renderArrayFilter = (filter: FieldQuery, index: number) => { + + const renderArrayFilter = (filter: FQ.FieldQuery, index: number) => { const { fieldName, operatorSymbol, operand, subOperatorSymbol = '' } = decodeFieldQuery(filter, fields); const field = fieldName ? fields[fieldName] : null; const subField = field && field.type === 'array' ? field.subfield : null; @@ -982,8 +243,8 @@ export const Filters = (props: FiltersProps) => { symbol = ` ${operatorSymbol} ${subOperatorSymbol}`; } - let operandLabel: string = ''; - + let operandLabel = ''; + if (subField) { if (subField.type === 'enum') { if (Array.isArray(operand)) { @@ -995,16 +256,13 @@ export const Filters = (props: FiltersProps) => { operandLabel = operand; } } - + return ( { onRemoveFilter?.(index); }} - primary - small - > - {fieldNameLabel + onRemove={() => { onRemoveFilter?.(index); }} + content={ + fieldNameLabel ? ( <> {`${fieldNameLabel}${symbol}`} @@ -1012,7 +270,7 @@ export const Filters = (props: FiltersProps) => { ) : `${operandLabel}` } - + /> ); }; @@ -1037,7 +295,7 @@ export const Filters = (props: FiltersProps) => { } } - let operandLabel; + let operandLabel: string; if (field && field.type === 'enum') { if (Array.isArray(operand)) { @@ -1048,7 +306,6 @@ export const Filters = (props: FiltersProps) => { } else if (field && field.type === 'dictionary') { operandLabel = Object.keys(operand).map(key => { const keyLabel = field.suggestedKeys?.[key]?.label ?? key; - return `${keyLabel} = ${operand[key]}`; }).join(', '); } else { @@ -1059,11 +316,9 @@ export const Filters = (props: FiltersProps) => { { onRemoveFilter?.(index); }} - primary - small - > - {fieldNameLabel + onRemove={() => { onRemoveFilter?.(index); }} + content={ + fieldNameLabel ? ( <> {`${fieldNameLabel}${symbol}`} @@ -1071,7 +326,7 @@ export const Filters = (props: FiltersProps) => { ) : `${operandLabel}` } - + /> ); }; @@ -1105,12 +360,122 @@ export const Filters = (props: FiltersProps) => { ); }; -type MultiSearchInputProps = ComponentPropsWithoutRef & { + +// +// Suggestions dropdown +// + +const SuggestionItem = Dropdown.Item; + +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: Fields, fieldQueryBuffer: FieldQueryBuffer, inputRef: React.RefObject, }; -export const SearchInput = React.forwardRef((props, ref) => { +export const SearchInput = (props: SearchInputProps) => { const { className, onKeyDown, @@ -1122,8 +487,6 @@ export const SearchInput = React.forwardRef - + {field && {field.label}{operator}{subOperator} {key ? `${key} =` : ''} }
); -}); +}; type FieldsDropdownProps = { inputRef?: React.RefObject, @@ -1276,16 +639,16 @@ const AlternativesDropdown = (props: AlternativesDropdownProps) => { validator, } = props; - const [selectedAlternatives, setSelectedAlternatives] = React.useState([]); - + const [selectedAlternatives, setSelectedAlternatives] = React.useState>([]); + const canSelectMultipleItems = ['$in', '$nin', '$any', '$all'].includes(selectedOperator); - + const onOptionClick = (key?: string) => { if (typeof key !== 'undefined') { onChange([key]); } }; - + const ValidateSelection = () => { let isValid = false; let message = ''; @@ -1297,55 +660,48 @@ const AlternativesDropdown = (props: AlternativesDropdownProps) => { isValid = validatorResponse.isValid; message = validatorResponse.message; } - + return { isValid, message }; }; - + const arrayValidation = ValidateSelection(); - + const onSelectionComplete = () => { onChange(selectedAlternatives); }; - - const onSelectionChange = (event: React.ChangeEvent) => { - const target = event.target; - const checked = target.checked; - const value = target.value; - if (checked) { - setSelectedAlternatives([...selectedAlternatives, value]); + + const onSelectionChange = (alternativeName: string, shouldBeChecked: boolean) => { + if (shouldBeChecked) { + setSelectedAlternatives([...selectedAlternatives, alternativeName]); } else { - setSelectedAlternatives([...selectedAlternatives.filter(item => item !== value)]); + setSelectedAlternatives([...selectedAlternatives.filter(item => item !== alternativeName)]); } }; - + const renderMultiSelectAlternatives = () => ( <> - - {Object.entries(alternatives || {}).map(([alternativesName, { label }]) => ( - ( + { onSelectionChange(alternativesName, event.target.checked); }} /> ))} - - <> - {!arrayValidation.isValid && arrayValidation.message && ( - - {arrayValidation.message} - - )} - + + {!arrayValidation.isValid && arrayValidation.message && ( + + {arrayValidation.message} + + )}
@@ -1587,9 +943,9 @@ const DateTimeDropdown = (props: DateTimeDropdownProps) => {
@@ -1766,12 +1122,12 @@ export const initializeFieldQueryBuffer = (): FieldQueryBuffer => ({ value: '', }); -export type MultiSearchProps = Omit, 'className'|'children'> & { +export type MultiSearchProps = Omit, 'className'|'children'> & { className?: ClassNameArgument, - fields: Fields, + fields: FQ.Fields, popperOptions?: Dropdown.PopperOptions, - query?: (filters: FilterQuery) => void; - filters?: FilterQuery, + query?: (filters: FQ.FilterQuery) => void; + filters?: FQ.FilterQuery, }; export const MultiSearch = (props: MultiSearchProps) => { const inputRef = React.useRef(null); @@ -1780,11 +1136,11 @@ export const MultiSearch = (props: MultiSearchProps) => { className, fields, popperOptions: customPopperOptions, - query, + query = () => {}, onFocus, onClick, disabled, - filters: customFilters, + filters: customFilters = FQ.createFilterQuery(), } = props; const { filters, addFilter, removeFilter, removeAllFilters } = useFilters({ @@ -1795,7 +1151,7 @@ export const MultiSearch = (props: MultiSearchProps) => { 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 ?? {}), @@ -1808,7 +1164,7 @@ export const MultiSearch = (props: MultiSearchProps) => { const validateFieldQuery = (fieldQueryBuffer: FieldQueryBuffer): ValidatorResponse => { let isValid = fieldQueryBuffer.value?.trim() !== ''; let message = ''; - + if (fieldQueryBuffer.fieldName) { const field: Field = fields[fieldQueryBuffer.fieldName]; if (field.type === 'text') { @@ -1896,7 +1252,7 @@ export const MultiSearch = (props: MultiSearchProps) => { 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; @@ -1929,7 +1285,7 @@ export const MultiSearch = (props: MultiSearchProps) => { } else { updateFieldQueryBuffer(initializeFieldQueryBuffer()); } - + validateFieldQuery(fieldQueryBuffer); } }; @@ -1970,24 +1326,24 @@ export const MultiSearch = (props: MultiSearchProps) => { const onFieldClick = (fieldName?: string) => { if (!fieldName) { return; } - + const field = fields[fieldName]; const newFieldQuery: FieldQueryBuffer = { ...fieldQueryBuffer, fieldName, }; - + if (['number', 'datetime', 'enum', 'array'].includes(field.type) && field.operators.length === 1) { newFieldQuery.operator = field.operators[0]; } - + if (field.type === 'array' && field.subfield.operators.length === 1) { newFieldQuery.subOperator = field.subfield.operators[0]; } - + updateFieldQueryBuffer(newFieldQuery); - + if (inputRef.current) { inputRef.current.focus(); } @@ -2011,25 +1367,25 @@ export const MultiSearch = (props: MultiSearchProps) => { 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; } @@ -2069,7 +1425,7 @@ export const MultiSearch = (props: MultiSearchProps) => { /> ); }; - + const renderDateTimeSelectorDropdown = () => { const { fieldName, operator } = fieldQueryBuffer; @@ -2078,7 +1434,7 @@ export const MultiSearch = (props: MultiSearchProps) => { const field = fields[fieldName]; if (field?.type !== 'datetime') { return null; } - + const isActive = isInputFocused && field && !!operator @@ -2086,14 +1442,14 @@ export const MultiSearch = (props: MultiSearchProps) => { const onDateTimeRangeChange = (value: number | [number, number]) => { addFilter({ fieldName, value, selectedOperator: operator }); - + updateFieldQueryBuffer(initializeFieldQueryBuffer()); - + if (inputRef.current) { inputRef.current.focus(); } }; - + const canSelectDateTimeRange = () => { return operator === '$range'; }; @@ -2113,7 +1469,7 @@ export const MultiSearch = (props: MultiSearchProps) => { /> ); }; - + const renderSuggestedKeysDropdown = () => { const { fieldName } = fieldQueryBuffer; @@ -2145,16 +1501,16 @@ export const MultiSearch = (props: MultiSearchProps) => { /> ); }; - + 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' @@ -2164,7 +1520,7 @@ export const MultiSearch = (props: MultiSearchProps) => { ) { return null; } const isFieldSupported = field && operatorTypes.includes(field.type); - + const isActive = isInputFocused && isFieldSupported && !fieldQueryBuffer.operator @@ -2176,13 +1532,13 @@ export const MultiSearch = (props: MultiSearchProps) => { operator?: NumberFieldOperator | DateTimeFieldOperator | EnumFieldOperator | ArrayFieldOperator, ) => { if (typeof operator === 'undefined') { return; } - + const newFieldQuery = { ...fieldQueryBuffer, operator }; if (field.type === 'array' && field.subfield.operators.length === 1) { newFieldQuery.subOperator = field.subfield.operators[0]; } - + updateFieldQueryBuffer(newFieldQuery); if (inputRef.current) { @@ -2203,28 +1559,27 @@ export const MultiSearch = (props: MultiSearchProps) => { /> ); }; - + 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') - ) { + + if (!subField || (subField.type !== 'number' && subField.type !== 'enum')) { return null; } - + const isFieldSupported = subField && subOperatorTypes.includes(subField.type); - + const isActive = isInputFocused && isFieldSupported && !!operator @@ -2272,9 +1627,9 @@ export const MultiSearch = (props: MultiSearchProps) => { {renderOperatorsDropdown()} {renderSubOperatorsDropdown()} {!validatorResponse.isValid && validatorResponse.message && ( - + {validatorResponse.message} - + )} {
); }; -MultiSearch.displayName = 'MultiSearch'; diff --git a/src/components/tables/MultiSearch/filterQuery.ts b/src/components/tables/MultiSearch/filterQuery.ts new file mode 100644 index 00000000..bc8776a6 --- /dev/null +++ b/src/components/tables/MultiSearch/filterQuery.ts @@ -0,0 +1,647 @@ +/* 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: Array): 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; + +export const operators = uniq([ + ...enumFieldOperators, + ...arrayFieldOperators, + ...textFieldOperators, + ...numberFieldOperators, + ...dictionaryFieldOperators, + ...dateTimeFieldOperators, +] as const); +export type Operator = ValueOf; + +// Field specification +type Alternative = { label: string }; +type Alternatives = Record; +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 +}; + +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: any) => R; + +export type EnumFieldSpec = { + type: 'enum', + label: React.ReactNode, + operators: Array, + alternatives: Alternatives, + placeholder?: string, + operatorInfo?: OperatorInfo, + validator?: EnumValidator, + accessor?: Accessor, +}; +export type ArrayFieldSpec = { + type: 'array', + label: React.ReactNode, + operators: Array, + subfield: EnumFieldSpec | NumberFieldSpec, + placeholder?: string, + operatorInfo?: OperatorInfo, + validator?: ArrayValidator, + accessor?: Accessor, +}; +export type TextFieldSpec = { + type: 'text', + label: React.ReactNode, + operators: Array, + placeholder?: string, + operatorInfo?: OperatorInfo, + validator?: TextValidator, + accessor?: Accessor, +}; +export type NumberFieldSpec = { + type: 'number', + label: React.ReactNode, + operators: Array, + placeholder?: string, + operatorInfo?: OperatorInfo, + validator?: TextValidator, + accessor?: Accessor, +}; + +type DateType = Date | number; +export type SelectedDate = DateType | [DateType, DateType]; +export type OnAddFilter = (newFilter: FieldQuery, currentFilters: FilterQuery) => FilterQuery; + +export type DateTimeFieldSpec = { + type: 'datetime', + label: React.ReactNode, + operators: Array, + placeholder?: string, + selectedDate?: SelectedDate, + onAddFilter?: OnAddFilter, + maxDate?: Date | number, + minDate?: Date | number, + operatorInfo?: OperatorInfo, + validator?: DateTimeValidator, + accessor?: Accessor, +}; +type SuggestedKey = { label: string }; +type SuggestedKeys = { [key: string]: SuggestedKey }; +export type DictionaryFieldSpec = { + type: 'dictionary', + label: React.ReactNode, + operators: Array, + suggestedKeys?: SuggestedKeys, + placeholder?: string, + operatorInfo?: OperatorInfo, + validator?: DictionaryValidator, + accessor?: Accessor>, +}; +export type RecordFieldSpec = { + type: 'record', + label: React.ReactNode, + operators: Array, + fields: Fields, + placeholder?: string, + operatorInfo?: OperatorInfo, + 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 => []; + +const isRangeOperationValue = (input: unknown): input is RangeOperationValue => { + return Array.isArray(input) && input.length === 2 && typeof input[0] === 'number' && typeof input[1] === 'number'; +}; + +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') { + queryOperation = { $ne: value[0] }; + } else { + // Default to $eq + queryOperation = { $eq: value[0] }; + } + + return queryOperation; +}; + +const encodeArrayFieldQueryOperation = ( + operators: ArrayFieldOperator[], + value: Array | Primitive, + selectedOperator: ArrayFieldOperator, + selectedSubOperator: EnumFieldOperator | NumberFieldOperator | 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 (numberFieldOperators.includes(selectedSubOperator as NumberFieldOperator) && typeof value === 'string') { + // Remove comma and space from the value + 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 (numberFieldOperators.includes(selectedSubOperator as NumberFieldOperator) && typeof value === 'string') { + // Remove comma and space from the value + 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; + + if (isRangeOperationValue(value)) { + if (!value[0] || !value[1]) { + return null; + } + if (operators.includes('$range')) { + queryOperation = { $range: value }; + } else { + return null; + } + } 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 = []; + + if (operator === '$any' && '$any' in operation) { + if ('$or' in operation.$any && Array.isArray(operation.$any.$or)) { + operand = operation.$any.$or.map(value => { + if (typeof value !== 'object' || !value) { + return value; + } + + if ('$eq' in value) { + subOperatorSymbol = getOperatorLabel('$in', field.subfield); + return value.$eq; + } else { + return value; + } + }); + } else if ('$and' in operation.$any && Array.isArray(operation.$any.$and)) { + operand = operation.$any.$and.map(value => { + if (typeof value !== 'object' || !value) { + return value; + } + + if ('$ne' in value) { + subOperatorSymbol = getOperatorLabel('$nin', field.subfield); + return value.$ne; + } else { + return value; + } + }); + } else if (Object.keys(operation.$any)[0]) { + const subOperator = Object.keys(operation.$any)[0]; + + if (numberFieldOperators.includes(subOperator as NumberFieldOperator)) { + subOperatorSymbol = getOperatorLabel(subOperator as Operator, field.subfield); + operand = Object.values(operation.$any)[0]; + } + } + } else if (operator === '$all' && '$all' in operation) { + if ('$or' in operation.$all && Array.isArray(operation.$all.$or)) { + operand = operation.$all.$or.map(value => { + if (typeof value !== 'object' || !value) { + return value; + } + + if ('$eq' in value) { + subOperatorSymbol = getOperatorLabel('$in', field.subfield); + return value.$eq; + } else { + return value; + } + }); + } else if ('$and' in operation.$all && Array.isArray(operation.$all.$and)) { + operand = operation.$all.$and.map(value => { + if (typeof value !== 'object' || !value) { + return value; + } + + if ('$ne' in value) { + subOperatorSymbol = getOperatorLabel('$nin', field.subfield); + return value.$ne; + } else { + return value; + } + }); + } else if (Object.keys(operation.$all)[0]) { + const subOperator = Object.keys(operation.$all)[0]; + + if (numberFieldOperators.includes(subOperator as NumberFieldOperator)) { + subOperatorSymbol = getOperatorLabel(subOperator as Operator, field.subfield); + operand = Object.values(operation.$all)[0]; + } + } + } 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, + }; +}; + +const decodeFieldQuery = (fieldQuery: FieldQuery, fields: Fields): { + fieldName: FieldName, + operator: Operator, + operatorSymbol: string, + operand: any, + subOperatorSymbol?: string, +} => { + const field = fieldQuery.fieldName ? fields[fieldQuery.fieldName] : null; + const fieldType = fieldQuery.fieldName ? fields[fieldQuery.fieldName].type : null; + + if (fieldType === 'enum') { + return decodeEnumFieldQuery(fieldQuery); + } else if (field && field.type === 'array') { + return decodeArrayFieldQuery(fieldQuery, field); + } else if (fieldType === 'number') { + return decodeNumberFieldQuery(fieldQuery); + } else if (fieldType === 'datetime') { + return decodeDateTimeFieldQuery(fieldQuery); + } + + const operator = Object.keys(fieldQuery.operation)[0] as Operator; + const operatorSymbol = ':'; + const operationValue = Object.values(fieldQuery.operation)[0]; + const operand = operator === '$text' ? operationValue?.$search || '' : operationValue; + + return { + fieldName: fieldQuery.fieldName, + operator, + operatorSymbol, + operand, + }; +}; diff --git a/src/components/text/Tag/Tag.tsx b/src/components/text/Tag/Tag.tsx index 79f16a79..f067e09d 100644 --- a/src/components/text/Tag/Tag.tsx +++ b/src/components/text/Tag/Tag.tsx @@ -12,7 +12,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, @@ -22,7 +22,7 @@ export type TagProps = ComponentProps<'div'> & { /** Callback to remove the tag. If set, display a close icon, otherwise it is hidden. */ onRemove?: () => void, }; - +type Test = ComponentProps<'div'>['content']; /** * A tag component. */ diff --git a/src/util/hooks/useFocus.ts b/src/util/hooks/useFocus.ts new file mode 100644 index 00000000..1646fe64 --- /dev/null +++ b/src/util/hooks/useFocus.ts @@ -0,0 +1,45 @@ + +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..4762c6f7 --- /dev/null +++ b/src/util/hooks/useOutsideClickHandler.ts @@ -0,0 +1,33 @@ + +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..6c791062 --- /dev/null +++ b/src/util/objectUtil.test.ts @@ -0,0 +1,304 @@ + +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..3a5d50c1 --- /dev/null +++ b/src/util/objectUtil.ts @@ -0,0 +1,146 @@ + +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..fe938aa1 --- /dev/null +++ b/src/util/random.ts @@ -0,0 +1,6 @@ + +// 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(''); +}; From 01e4a9d8587b1bda1af23edf4d4b83af22a5a988 Mon Sep 17 00:00:00 2001 From: "Imad A. Bakir" Date: Tue, 3 Dec 2024 06:59:21 +0100 Subject: [PATCH 05/32] PLAT-3985 WIP --- package-lock.json | 29 +++ package.json | 5 +- package.json.js | 5 +- .../tables/DataTable/DataTable.stories.tsx | 2 +- .../tables/DataTable/DataTableContext.tsx | 2 +- .../tables/DataTable/DataTableEager.scss | 2 +- .../DataTable/DataTableEager.stories.tsx | 211 +++++++++++++++++ .../tables/DataTable/DataTableEager.tsx | 17 +- .../tables/DataTable/DataTableLazy.scss | 2 +- .../DataTable/DataTableLazy.stories.tsx | 172 ++++++++++++++ .../tables/DataTable/DataTableLazy.tsx | 24 +- .../tables/DataTable/DataTableStream.scss | 2 +- .../DataTable/DataTableStream.stories.tsx | 220 ++++++++++++++++++ .../tables/DataTable/DataTableStream.tsx | 36 ++- .../tables/DataTable/filtering/Filtering.ts | 115 ++++----- .../DataTable/pagination/Pagination.scss | 4 +- .../DataTable/pagination/Pagination.tsx | 86 ++++--- .../pagination/PaginationSizeSelector.scss | 4 +- .../pagination/PaginationSizeSelector.tsx | 64 ++--- .../pagination/PaginationStream.scss | 4 +- .../DataTable/pagination/PaginationStream.tsx | 29 +-- .../DataTable/plugins/useCustomFilters.tsx | 2 +- .../DataTable/plugins/useRowSelectColumn.scss | 2 +- .../DataTable/plugins/useRowSelectColumn.tsx | 4 +- .../tables/DataTable/table/DataTable.scss | 4 +- .../tables/DataTable/table/DataTable.tsx | 4 +- .../DataTable/table/DataTablePlaceholder.scss | 4 +- .../MultiSearch/MultiSearch.stories.tsx | 53 ++--- .../tables/MultiSearch/MultiSearch.tsx | 158 +++++++------ .../tables/MultiSearch/filterQuery.ts | 14 +- .../SearchInput/SearchInput.stories.tsx | 36 +++ .../tables/SearchInput/SearchInput.tsx | 31 +++ src/components/tables/util/async_util.ts | 13 ++ src/components/tables/util/generateData.ts | 14 +- src/components/tables/util/hooks.ts | 20 ++ src/util/componentUtil.ts | 15 +- 36 files changed, 1087 insertions(+), 322 deletions(-) create mode 100644 src/components/tables/DataTable/DataTableEager.stories.tsx create mode 100644 src/components/tables/DataTable/DataTableLazy.stories.tsx create mode 100644 src/components/tables/DataTable/DataTableStream.stories.tsx create mode 100644 src/components/tables/SearchInput/SearchInput.stories.tsx create mode 100644 src/components/tables/SearchInput/SearchInput.tsx create mode 100644 src/components/tables/util/async_util.ts create mode 100644 src/components/tables/util/hooks.ts diff --git a/package-lock.json b/package-lock.json index 77f31bc3..5350459d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@chromatic-com/storybook": "^3.2.2", + "@ngneat/falso": "^6.4.0", "@percy/cli": "^1.30.2", "@percy/storybook": "^6.0.2", "@storybook/addon-a11y": "^8.4.4", @@ -1773,6 +1774,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", @@ -11401,6 +11423,13 @@ "integrity": "sha512-HHqQ/SqbeiDfXXVKgNxTpbQTD4n7IUb4hZATvHjp03jr3TF7igehCyHdOjeYTrzIseLO93cTTfSb5f4qWcirMQ==", "license": "MIT" }, + "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": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", diff --git a/package.json b/package.json index 4d7cee12..3d9a4679 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "check:types": "tsc --noEmit", "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:unit": "vitest run --root=.", "test": "npm run check:types; npm run lint:style; npm run test:unit", "test-ui": "vitest --ui", @@ -91,7 +91,8 @@ "lightningcss": "^1.28.1", "@types/react": "npm:types-react@rc", "@types/react-dom": "npm:types-react-dom@rc", - "@types/react-table": "^7.7.20" + "@types/react-table": "^7.7.20", + "@ngneat/falso": "^6.4.0" }, "dependencies": { "date-fns": "^4.1.0", diff --git a/package.json.js b/package.json.js index 84c49e0f..d5634cf1 100644 --- a/package.json.js +++ b/package.json.js @@ -142,6 +142,9 @@ const packageConfig = { '@types/react-dom': 'npm:types-react-dom@rc', '@types/react-table': '^7.7.20', + + // Fake data + "@ngneat/falso": "^6.4.0", }, // Dependencies needed when running the generated build @@ -187,4 +190,4 @@ const packageConfigWithComment = { const packageConfigFormatted = JSON.stringify(packageConfigWithComment, null, 2); // Write to `package.json` -fs.writeFileSync('./package.json', packageConfigFormatted + '\n'); +fs.writeFileSync('./package.json', `${packageConfigFormatted}\n`); diff --git a/src/components/tables/DataTable/DataTable.stories.tsx b/src/components/tables/DataTable/DataTable.stories.tsx index e7c0f786..507b87e6 100644 --- a/src/components/tables/DataTable/DataTable.stories.tsx +++ b/src/components/tables/DataTable/DataTable.stories.tsx @@ -15,7 +15,7 @@ type Story = StoryObj; import * as ReactTable from 'react-table'; -import { createTableContext, TableContextState } from './DataTableContext.tsx'; +import { createTableContext, type TableContextState } from './DataTableContext.tsx'; const DataTableContext = ({ children }: React.PropsWithChildren) => { type User = { name: string }; const columns = React.useMemo>>(() => [ diff --git a/src/components/tables/DataTable/DataTableContext.tsx b/src/components/tables/DataTable/DataTableContext.tsx index 1308493f..62335ea0 100644 --- a/src/components/tables/DataTable/DataTableContext.tsx +++ b/src/components/tables/DataTable/DataTableContext.tsx @@ -3,7 +3,7 @@ |* 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 * as ReactTable from 'react-table'; export type DataTableStatus = { diff --git a/src/components/tables/DataTable/DataTableEager.scss b/src/components/tables/DataTable/DataTableEager.scss index 660c6363..edafd0f9 100644 --- a/src/components/tables/DataTable/DataTableEager.scss +++ b/src/components/tables/DataTable/DataTableEager.scss @@ -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/. */ -@use '../../../style/variables.scss' as bkl; +// @use '../../../style/variables.scss' as bkl; @use './DataTableLazy.scss' as dataTableLazy; diff --git a/src/components/tables/DataTable/DataTableEager.stories.tsx b/src/components/tables/DataTable/DataTableEager.stories.tsx new file mode 100644 index 00000000..cf8c7a09 --- /dev/null +++ b/src/components/tables/DataTable/DataTableEager.stories.tsx @@ -0,0 +1,211 @@ +/* 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 cx from 'classnames'; +import { differenceInDays } from 'date-fns'; +import React from 'react'; + +import { delay } from '../util/async_util.ts'; +import { type User, generateData } from '../util/generateData.ts'; +import { useEffectAsync } from '../util/hooks.ts'; +import * as Filtering from './filtering/Filtering.ts'; + +import { Panel } from '../../containers/Panel/Panel.tsx'; +import * as DataTablePlugins from './plugins/useRowSelectColumn.tsx'; +import * as DataTableEager from './DataTableEager.tsx'; + + +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' }), + disableSortBy: false, + disableGlobalFilter: false, + }, +]; + +const 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: (user: User) => differenceInDays(new Date(), 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} + > + + + + + ); +}; + +// 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); + + React.useEffect(() => { + const filtered = Filtering.filterByQuery(fields, props.items, filters); + setFilteredItems(Object.values(filtered)); + }, [filters, props.items]); + + return ( + + item.id} + plugins={[DataTablePlugins.useRowSelectColumn]} + > + + + + ); +}; + +export default { + title: 'Components/Tables/DataTableEager', + 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 SlowNetwork = { + args: { + columns, + items: generateData({ numItems: 1000 }), + delay: 1500, + isReady: false, + }, + render: (args: dataTeableEagerTemplateProps) => , +}; + +export const InfiniteDelay = { + args: { + columns, + items: generateData({ numItems: 10 }), + delay: Number.POSITIVE_INFINITY, + isReady: false, + }, + render: (args: dataTeableEagerTemplateProps) => , +}; + +export const WithFilter = { + args: { + columns, + items: generateData({ numItems: 45 }), + }, + render: (args: dataTeableEagerTemplateProps) => , +}; diff --git a/src/components/tables/DataTable/DataTableEager.tsx b/src/components/tables/DataTable/DataTableEager.tsx index 912908fa..c1ad76d0 100644 --- a/src/components/tables/DataTable/DataTableEager.tsx +++ b/src/components/tables/DataTable/DataTableEager.tsx @@ -3,16 +3,16 @@ |* 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, ClassNameArgument } from '../../../util/componentUtil.ts'; +import { classNames as cx, type ClassNameArgument } from '../../../util/componentUtil.ts'; import * as ReactTable from 'react-table'; -import { TableContextState, createTableContext, useTable } from './DataTableContext.tsx'; +import { type TableContextState, createTableContext, useTable } from './DataTableContext.tsx'; import { Pagination } from './pagination/Pagination.tsx'; -import { SearchInput } from '../../../prefab/forms/SearchInput/SearchInput.tsx'; -import { MultiSearch as MultiSearchInput } from '../../../prefab/forms/MultiSearch/MultiSearch.tsx'; -import { DataTableSync } from './table/DataTable'; +import { SearchInput } from '../SearchInput/SearchInput.tsx'; +import { MultiSearch as MultiSearchInput } from '../MultiSearch/MultiSearch.tsx'; +import { DataTableSync } from './table/DataTable.tsx'; -import './DataTableEager.scss'; +// import './DataTableEager.scss'; export * from './DataTableContext'; export { Pagination }; @@ -44,15 +44,14 @@ export const TableProviderEager = (props: TableProviderEagerPr isReady = true, } = props; - const tableOptions = { + const tableOptions: ReactTable.TableOptions = { columns, data: items, - getRowId, + ...(getRowId && { getRowId }), }; const table = ReactTable.useTable( { ...tableOptions, - defaultColumn: { disableGlobalFilter: true, disableSortBy: true, diff --git a/src/components/tables/DataTable/DataTableLazy.scss b/src/components/tables/DataTable/DataTableLazy.scss index 6de6b2fd..06467211 100644 --- a/src/components/tables/DataTable/DataTableLazy.scss +++ b/src/components/tables/DataTable/DataTableLazy.scss @@ -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/. */ -@use '../../../style/variables.scss' as bkl; +// @use '../../../style/variables.scss' as bkl; @use '../../../style/mixins.scss' as mixins; diff --git a/src/components/tables/DataTable/DataTableLazy.stories.tsx b/src/components/tables/DataTable/DataTableLazy.stories.tsx new file mode 100644 index 00000000..6613feb8 --- /dev/null +++ b/src/components/tables/DataTable/DataTableLazy.stories.tsx @@ -0,0 +1,172 @@ +/* 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 React, { useState, useMemo, useCallback } from 'react'; + +import { delay } from '../util/async_util.ts'; +import { type User, generateData } from '../util/generateData.ts'; + +import { Button } from '../../actions/Button/Button.tsx'; +import { Panel } from '../../containers/Panel/Panel.tsx'; +import * as DataTableLazy from './DataTableLazy.tsx'; + +export default { + title: 'Components/Tables/DataTableLazy', + component: DataTableLazy.DataTableLazy, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, +}; + +type dataTeableLazyTemplateProps = DataTableLazy.TableProviderLazyProps & { delay: number, items: Array }; +const DataTableLazyTemplate = (props: dataTeableLazyTemplateProps) => { + const columns = useMemo(() => props.columns, [props.columns]); + const items = useMemo(() => props.items, [props.items]); + const delayQuery = props.delay ?? null; + + const [itemsProcessed, setItemsProcessed] = useState>({ total: 0, itemsPage: [] }); + + const query: DataTableLazy.DataTableQuery = 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' }), + 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 index bb7e32bb..c95ef519 100644 --- a/src/components/tables/DataTable/DataTableLazy.tsx +++ b/src/components/tables/DataTable/DataTableLazy.tsx @@ -3,20 +3,20 @@ |* 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, ClassNameArgument } from '../../../util/component_util'; -import { useEffectAsync } from '../../../util/hooks'; +import { classNames as cx, ClassNameArgument } from '../../../util/componentUtil.ts'; +import { useEffectAsync } from '../util/hooks.ts'; -import { Loader } from '../../overlays/loader/Loader'; -import { Button } from '../../buttons/Button'; +import { Spinner } from '../../graphics/Spinner/Spinner.tsx'; +import { Button } from '../../actions/Button/Button.tsx'; import * as ReactTable from 'react-table'; -import { DataTableStatus, TableContextState, createTableContext, useTable } from './DataTableContext'; +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 { Icon } from '../../graphics/Icon/Icon.tsx'; -import './DataTableLazy.scss'; -import { BaklavaIcon } from '../../icons/icon-pack-baklava/BaklavaIcon'; +// import './DataTableLazy.scss'; export * from './DataTableContext'; @@ -139,10 +139,10 @@ export const TableProviderLazy = (props: TableProviderLazyProp // Controlled table state const [pageSize, setPageSize] = React.useState(initialState?.pageSize ?? 10); - const tableOptions = { + const tableOptions: ReactTable.TableOptions = { columns, data: items.itemsPage, - getRowId, + ...(getRowId && { getRowId }), // Add `getRowId` only if it is defined }; const table = ReactTable.useTable( { @@ -280,15 +280,15 @@ export const DataTableLazy = ({ className, footer, ...propsRest }: DataTableLazy placeholderError={ { reload(); }}> - Retry + } /> } {...propsRest} > - {showLoadingIndicator && } + {showLoadingIndicator && } ); }; diff --git a/src/components/tables/DataTable/DataTableStream.scss b/src/components/tables/DataTable/DataTableStream.scss index 55eafbf4..d0f2b4a5 100644 --- a/src/components/tables/DataTable/DataTableStream.scss +++ b/src/components/tables/DataTable/DataTableStream.scss @@ -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/. */ -@use '../../../style/variables.scss' as bkl; +// @use '../../../style/variables.scss' as bkl; @use './DataTableLazy.scss' as dataTableLazy; diff --git a/src/components/tables/DataTable/DataTableStream.stories.tsx b/src/components/tables/DataTable/DataTableStream.stories.tsx new file mode 100644 index 00000000..64852f67 --- /dev/null +++ b/src/components/tables/DataTable/DataTableStream.stories.tsx @@ -0,0 +1,220 @@ +/* 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 React, { useState, useMemo, useCallback } from 'react'; + +import { delay } from '../util/async_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 * as DataTableStream from './DataTableStream.tsx'; + +export default { + title: 'Components/Tables/DataTableStream', + 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 }; +const DataTableStreamTemplate = (props : dataTeableLazyTemplateProps) => { + const columns = useMemo(() => props.columns, [props.columns]); + const items = useMemo(() => props.items, [props.items]); + const delayQuery = props.delay ?? null; + + const [itemsProcessed, setItemsProcessed] = useState>([]); + + const query: DataTableStream.DataTableQuery = 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); + + const previousItemIndex = items.indexOf(previousItem); + const 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) : undefined; + return typeof cell === 'string' && cell.toLowerCase().includes(globalFilter.trim().toLowerCase()); + }); + }) + .sort((a, b) => { + if (!orderings.length) return 0; + const { column, direction } = orderings[0]; + const factor = direction === 'DESC' ? -1 : 1; + return a[column]?.localeCompare(b[column]) * factor || 0; + }) + .slice(offset, offset + limit); + + return { itemsPage: filteredItems, pageState: null, isEndOfStream: props.endOfStream }; + }, + [items, columns, props.endOfStream, delayQuery] + ); + + return ( + + + + + + + ); +}; + +// 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' }), + 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: 'I have no idea', + }, + }, + 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 index 0a9c0304..e8ca23b9 100644 --- a/src/components/tables/DataTable/DataTableStream.tsx +++ b/src/components/tables/DataTable/DataTableStream.tsx @@ -3,24 +3,24 @@ |* 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/component_util'; +import { classNames as cx } from '../../../util/componentUtil.ts'; -import { Loader } from '../../overlays/loader/Loader'; -import { Button } from '../../buttons/Button'; import * as ReactTable from 'react-table'; -import { DataTableStatus, TableContextState, createTableContext, useTable } from './DataTableContext'; +import { type DataTableStatus, type TableContextState, createTableContext, useTable } from './DataTableContext'; import { PaginationStream } from './pagination/PaginationStream'; import { DataTablePlaceholderError } from './table/DataTablePlaceholder'; import { DataTableAsync } from './table/DataTable'; -import type { FilterQuery } from '../../../prefab/forms/MultiSearch/MultiSearch'; +import type { FilterQuery } from '../MultiSearch/filterQuery.ts'; // Table plugins import { useCustomFilters } from './plugins/useCustomFilters'; // Styles -import './DataTableStream.scss'; +// import './DataTableStream.scss'; +import { Spinner } from '../../graphics/Spinner/Spinner.tsx'; +import { Button } from '../../actions/Button/Button.tsx'; export * from './DataTableContext'; @@ -51,9 +51,8 @@ const usePageHistory = () => { 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) - } else { - return new Map([...pageHistory.entries()].filter(([pageIndexCurrent]) => pageIndexCurrent <= pageIndex)); } + return new Map([...pageHistory.entries()].filter(([pageIndexCurrent]) => pageIndexCurrent <= pageIndex)); }, [], ); @@ -75,7 +74,7 @@ const usePageHistory = () => { const lastIndex: undefined | 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 + throw new Error('Non-contiguous page indices'); // Should never happen } const history = new Map(pageHistory).set(pageIndex, pageHistoryItem); @@ -94,7 +93,7 @@ const usePageHistory = () => { return pageHistoryApi(pageHistory); }; -export type DataTableQueryResult = { +export type DataTableQueryResult = { itemsPage: ReactTableOptions['data'], // Custom page state to be stored in page history pageState?: P, @@ -102,7 +101,7 @@ export type DataTableQueryResult = +export type DataTableQuery = (params: { previousItem: null | D, previousPageState?: undefined | P, @@ -116,7 +115,7 @@ export type DataTableQuery = customFilters: FilterQuery, }) => Promise>; -export type TableProviderStreamProps = { +export type TableProviderStreamProps = { children: React.ReactNode, columns: ReactTableOptions['columns'], getRowId: ReactTableOptions['getRowId'], @@ -131,7 +130,7 @@ export type TableProviderStreamProps) => void, }; -export const TableProviderStream = ( +export const TableProviderStream = ( props: TableProviderStreamProps, ) => { const { @@ -165,7 +164,7 @@ export const TableProviderStream = ( { @@ -440,7 +439,6 @@ export const TableProviderStream = { - return ; + return ; }; // Use `` by default, unless the table is empty (in which case there are "zero" pages) @@ -496,7 +494,7 @@ export const DataTableStream = ({ ? null : ( <>} /> ); const footerWithFallback = typeof footer === 'undefined' ? footerDefault : footer; @@ -514,7 +512,7 @@ export const DataTableStream = ({ placeholderError={ { reload(); }}> + } @@ -527,7 +525,7 @@ export const DataTableStream = ({ } {...propsRest} > - {showLoadingIndicator && } + {showLoadingIndicator && } ); }; diff --git a/src/components/tables/DataTable/filtering/Filtering.ts b/src/components/tables/DataTable/filtering/Filtering.ts index 817a8732..2553ebdc 100644 --- a/src/components/tables/DataTable/filtering/Filtering.ts +++ b/src/components/tables/DataTable/filtering/Filtering.ts @@ -12,7 +12,7 @@ import type { Field, TypeOfFieldSpec, TypeOfFieldsSpec, -} from '../../../../prefab/forms/MultiSearch/MultiSearch'; +} from '../../MultiSearch/filterQuery.ts'; type Primitive = string | number; type Uuid = string; @@ -24,29 +24,27 @@ const parseDateTime = (date: Date): number => { const parseStringField = (field: Primitive) => { if (typeof field === 'string') { return field.trim().toLowerCase(); - } else { - return field; } + return field; }; const numericOperation = (numericField: number, operation: FieldQuery['operation']): boolean => { if ('$eq' in operation) { return numericField === operation.$eq; - } else if ('$ne' in operation) { + } if ('$ne' in operation) { return numericField !== operation.$ne; - } else if ('$gte' in operation) { + } if ('$gte' in operation) { return numericField >= operation.$gte; - } else if ('$gt' in operation) { + } if ('$gt' in operation) { return numericField > operation.$gt; - } else if ('$lte' in operation) { + } if ('$lte' in operation) { return numericField <= operation.$lte; - } else if ('$lt' in operation) { + } if ('$lt' in operation) { return numericField < operation.$lt; - } else if ('$range' in operation) { + } if ('$range' in operation) { return numericField >= operation.$range[0] && numericField <= operation.$range[1]; - } else { - throw new TypeError(`Unknown query operator`); } + throw new TypeError('Unknown query operator'); }; const matchesFieldQuery = ( @@ -61,12 +59,11 @@ const matchesFieldQuery = ( } 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()); - } else { - throw new TypeError(`Unknown query operator`); } + throw new TypeError('Unknown query operator'); } case 'datetime': { const fieldAsDate = parseDateTime(field as Date); // Unsafe but guaranteed by `TypeOfFieldSpec` @@ -74,124 +71,113 @@ const matchesFieldQuery = ( } case 'array': { const fieldAsArray = field as Array>; // Unsafe but guaranteed by `S` - + if ('$eq' in operation) { return fieldAsArray.every(field => (operation.$eq as typeof fieldAsArray).indexOf(field) >= 0); - } else if ('$ne' in operation) { + } if ('$ne' in operation) { return fieldAsArray.every(field => (operation.$ne as typeof fieldAsArray).indexOf(field) < 0); - } else if ('$all' in operation) { + } if ('$all' in operation) { const elementFieldSpec = fieldSpec.subfield; return fieldAsArray.every(element => { if ('$and' in operation.$all && Array.isArray(operation.$all.$and)) { const operations = operation.$all.$and; return operations.every(operation => matchesFieldQuery(elementFieldSpec, element, operation)); - } else if ('$or' in operation.$all && Array.isArray(operation.$all.$or)) { + } if ('$or' in operation.$all && Array.isArray(operation.$all.$or)) { const operations = operation.$all.$or; return operations.some(operation => matchesFieldQuery(elementFieldSpec, element, operation)); - } else { - throw new TypeError(`Unsupported array operation`); } + throw new TypeError('Unsupported array operation'); }); - } else if ('$any' in operation) { + } if ('$any' in operation) { return fieldAsArray.some(element => { const elementFieldSpec = fieldSpec.subfield; if ('$and' in operation.$any && Array.isArray(operation.$any.$and)) { const operations = operation.$any.$and; return operations.every(operation => matchesFieldQuery(elementFieldSpec, element, operation)); - } else if ('$or' in operation.$any && Array.isArray(operation.$any.$or)) { + } if ('$or' in operation.$any && Array.isArray(operation.$any.$or)) { const operations = operation.$any.$or; return operations.some(operation => matchesFieldQuery(elementFieldSpec, element, operation)); - } else { - throw new TypeError(`Unsupported array operation`); } + throw new TypeError('Unsupported array operation'); }); - } else { - throw new TypeError(`Unknown query operator`); } + throw new TypeError('Unknown query operator'); } case 'dictionary': { const fieldAsDictionary = field as string; // Unsafe but guaranteed by `S` - + if ('$all' in operation) { return fieldAsDictionary.includes(Object.values(operation.$all)[0]); - } else { - throw new TypeError(`Unknown query operator`); } + 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; - } else if ('$nin' in operation) { + } if ('$nin' in operation) { return operation.$nin.indexOf(fieldAsEnum) === -1; - } else if ('$eq' in operation) { + } if ('$eq' in operation) { return fieldAsEnum.includes(operation.$eq as string); - } else if ('$ne' in operation) { + } if ('$ne' in operation) { return !fieldAsEnum.includes(operation.$ne as string); - } else { - throw new TypeError(`Unknown query operator`); } + 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 => { 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)); - } else if ('$or' in operation.$all && Array.isArray(operation.$all.$or)) { + } if ('$or' in operation.$all && Array.isArray(operation.$all.$or)) { const operations = operation.$all.$or; return operations.some(operation => matchesFieldQuery(elementFieldSpec, element, operation)); - } else { - const fieldName: keyof RecordFieldSpec['fields'] = Object.keys(operation.$all)[0]; - const operations: FieldQuery['operation'] = Object.values(operation.$all)[0]; - if (typeof element === 'object' && element !== null && !Array.isArray(element)) { - const item = element as Record; - return matchesFieldQuery(elementFieldSpec, item[fieldName], operations); - } else { - return matchesFieldQuery(elementFieldSpec, element, operations); - } } + const fieldName: keyof RecordFieldSpec['fields'] = Object.keys(operation.$all)[0]; + const operations: FieldQuery['operation'] = Object.values(operation.$all)[0]; + if (typeof element === 'object' && element !== null && !Array.isArray(element)) { + const item = element as Record; + return matchesFieldQuery(elementFieldSpec, item[fieldName], operations); + } + return matchesFieldQuery(elementFieldSpec, element, operations); }); - } else if ('$any' in operation) { + } if ('$any' in operation) { return Object.values(fieldAsRecord).some(element => { 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)); - } else if ('$or' in operation.$any && Array.isArray(operation.$any.$or)) { + } if ('$or' in operation.$any && Array.isArray(operation.$any.$or)) { const operations = operation.$any.$or; return operations.some(operation => matchesFieldQuery(elementFieldSpec, element, operation)); - } else { - const fieldName: keyof RecordFieldSpec['fields'] = Object.keys(operation.$any)[0]; - const operations: FieldQuery['operation'] = Object.values(operation.$any)[0]; - if (typeof element === 'object' && element !== null && !Array.isArray(element)) { - const item = element as Record; - return matchesFieldQuery(elementFieldSpec, item[fieldName], operations); - } else { - return matchesFieldQuery(elementFieldSpec, element, operations); - } } + const fieldName: keyof RecordFieldSpec['fields'] = Object.keys(operation.$any)[0]; + const operations: FieldQuery['operation'] = Object.values(operation.$any)[0]; + if (typeof element === 'object' && element !== null && !Array.isArray(element)) { + const item = element as Record; + return matchesFieldQuery(elementFieldSpec, item[fieldName], operations); + } + return matchesFieldQuery(elementFieldSpec, element, operations); }); - } else { - throw new TypeError(`Unknown query operator`); } + throw new TypeError('Unknown query operator'); } - default: throw new TypeError(`Unknown field type`); + default: throw new TypeError('Unknown field type'); } }; const getFieldValue = (fieldSpec: Field, item: TypeOfFieldsSpec, fieldName: string) => { if (fieldSpec.accessor) { return fieldSpec.accessor(item); - } else if (fieldName !== '') { + } if (fieldName !== '') { return item[fieldName]; - } else { - throw new TypeError('Unable to get field value, expected either `accessor` or `fieldName` to be configured'); } + 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 @@ -221,7 +207,6 @@ export const filterByQuery = ( {} as Record, ); return itemsFiltered; - } else { - return items; } + return items; }; diff --git a/src/components/tables/DataTable/pagination/Pagination.scss b/src/components/tables/DataTable/pagination/Pagination.scss index 011aa53e..f5c1543d 100644 --- a/src/components/tables/DataTable/pagination/Pagination.scss +++ b/src/components/tables/DataTable/pagination/Pagination.scss @@ -2,8 +2,8 @@ |* 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 bkl; -@use '../../../../style/mixins.scss' as mixins; +// @use '../../../../style/variables.scss' as bkl; +// @use '../../../../style/mixins.scss' as mixins; .pagination { diff --git a/src/components/tables/DataTable/pagination/Pagination.tsx b/src/components/tables/DataTable/pagination/Pagination.tsx index c7713389..307db42c 100644 --- a/src/components/tables/DataTable/pagination/Pagination.tsx +++ b/src/components/tables/DataTable/pagination/Pagination.tsx @@ -4,37 +4,43 @@ import cx from 'classnames'; import * as React from 'react'; -import { joinElements } from '../../../../util/component_util'; +import { joinElements } from '../../../../util/componentUtil.ts'; -import { SpriteIcon as Icon } from '../../../icons/Icon'; +import { Icon } from '../../../graphics/Icon/Icon.tsx'; -import { PageSizeOption, PaginationSizeSelector } from './PaginationSizeSelector'; -import { useTable } from '../DataTableContext'; +import { type PageSizeOption, PaginationSizeSelector } from './PaginationSizeSelector.tsx'; +import { useTable } from '../DataTableContext.tsx'; -import './Pagination.scss'; +// import './Pagination.scss'; type PageOptionsSegment = Array; // Consecutive list of page indices (e.g. `[5, 6, 7]`) type PageOptions = Array; // List of segments (e.g. `[[1], [49, 50, 51], [100]]`) const combineSegments = (segment1: PageOptionsSegment, segment2: PageOptionsSegment): PageOptions => { - if (segment1.length === 0 || segment2.length === 0) { return [[...segment1, ...segment2]]; } - - const gapLeft = segment1.slice(-1)[0]; // Element to the left side of the gap (i.e. the last element of `segment1`) - const gapRight = segment2[0]; // Element to the right of the gap (i.e. the first element of `segment2`) - const gapSize = gapRight - gapLeft - 1; // Calculate the gap (if 0, then the segments are consecutive, e.g. 3 to 4) - + if (segment1.length === 0 || segment2.length === 0) { + return [[...segment1, ...segment2]]; + } + + const gapLeft = segment1[segment1.length - 1]; // Last element of `segment1` + const gapRight = segment2[0]; // First element of `segment2` + + // Ensure `gapLeft` and `gapRight` are defined before proceeding + if (gapLeft === undefined || gapRight === undefined) { + return [segment1, segment2]; + } + + const gapSize = gapRight - gapLeft - 1; // Calculate the gap if (gapSize > 1) { // If there is a gap between the segments larger than one, leave unmerged return [segment1, segment2]; - } else if (gapSize === 1) { + } if (gapSize === 1) { // If the gap is 1 (i.e. there is only one element "missing" in between), fill it in and merge // Motivation: there will be a separator between gaps (e.g. `4 5 ... 7 8`), so if there is only element in between, // then it makes sense to replace the separator with the missing element explicitly. return [[...segment1, gapLeft + 1, ...segment2]]; - } else { - // If there is no gap, combine the two segments (removing any overlapping elements) - return [[...segment1, ...segment2.filter((pageIndex: number) => pageIndex > gapLeft)]]; } + // If there is no gap, combine the two segments (removing any overlapping elements) + return [[...segment1, ...segment2.filter((pageIndex) => pageIndex > gapLeft)]]; }; const getPageOptions = ({ pageCount, pageIndex }: { pageCount: number, pageIndex: number }): PageOptions => { const pageIndexFirst = 0; @@ -54,11 +60,13 @@ const getPageOptions = ({ pageCount, pageIndex }: { pageCount: number, pageIndex return pageIndex >= pageIndexFirst && pageIndex <= pageIndexLast; }); - if (pageOptions.length === 0) { return [segment]; } + if (pageOptions.length === 0) { + return [segment]; + } // Split `pageOptions` into its last segment, and everything before: `[...pageOptionsBase, segmentPrior]` const pageOptionsBase: PageOptions = pageOptions.slice(0, -1); - const segmentPrior: PageOptionsSegment = pageOptions.slice(-1)[0]; + const segmentPrior: PageOptionsSegment = pageOptions.slice(-1)[0] || []; // Attempt to combine `segmentPrior` and `segment` into one consecutive segment (if there's no gap in between) return [...pageOptionsBase, ...combineSegments(segmentPrior, segment)]; @@ -78,7 +86,6 @@ export const Pagination = ({ pageSizeOptions }: PaginationProps) => { - table.state.pageIndex - table.state.pageSize - table.canPreviousPage - - table.canPreviousPage - table.canNextPage - table.pageOptions - table.pageCount @@ -94,11 +101,13 @@ export const Pagination = ({ pageSizeOptions }: PaginationProps) => { return (
- +
- @@ -106,22 +115,31 @@ export const Pagination = ({ pageSizeOptions }: PaginationProps) => { {joinElements( // Join the segments together with separator
  • elements inserted in between
  • , pageOptions - .map((pageOptionsSegment: PageOptionsSegment) => - <> - {pageOptionsSegment.map((pageIndex: number) => -
  • { table.gotoPage(pageIndex); }} - > - {pageIndex + 1} -
  • , - )} - , - ), + .map((pageOptionsSegment, segmentIndex) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: + + {pageOptionsSegment.map((pageIndex) => ( +
  • { + table.gotoPage(pageIndex); + }} + onKeyDown={() => { + table.gotoPage(pageIndex); + }} + > + {pageIndex + 1} +
  • + ))} +
    + )) )} - diff --git a/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss b/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss index d9876090..1e8f932f 100644 --- a/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss +++ b/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss @@ -2,8 +2,8 @@ |* 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 bkl; -@use '../../../../style/mixins.scss' as mixins; +// @use '../../../../style/variables.scss' as bkl; +// @use '../../../../style/mixins.scss' as mixins; .page-size-selector { diff --git a/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx b/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx index 877875cd..3867b5f0 100644 --- a/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx +++ b/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx @@ -2,55 +2,59 @@ |* 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 cx from 'classnames'; -import * as React from 'react'; +import type React from 'react'; -import { SpriteIcon as Icon } from '../../../icons/Icon'; -import { Button } from '../../../buttons/Button'; -import { Dropdown } from '../../../overlays/dropdown/Dropdown'; +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'; +import { useTable } from '../DataTableContext.tsx'; -import './PaginationSizeSelector.scss'; +// import './PaginationSizeSelector.scss'; export type PageSizeOption = number; export const defaultPageSizeOptions: Array = [10, 25, 50, 100]; type PaginationSizeSelectorProps = { - pageSizeOptions?: Array, - pageSizeLabel?: string; + pageSizeOptions?: Array | undefined, + pageSizeLabel?: string | undefined, }; export const PaginationSizeSelector = (props: PaginationSizeSelectorProps) => { const { pageSizeOptions = defaultPageSizeOptions, pageSizeLabel = 'Items per page' } = props; - + const { table } = useTable(); - + return (
    {pageSizeLabel}: - - + + ( + { + table.setPageSize(pageSize); + context.close(); + }} + /> + ))} + > + {({ props }) => ( + - } - > - {({ close }) => - pageSizeOptions.map(pageSize => - { table.setPageSize(pageSize); close(); }} - > - {pageSize} - , - ) - } - + )} +
    ); -}; +}; \ No newline at end of file diff --git a/src/components/tables/DataTable/pagination/PaginationStream.scss b/src/components/tables/DataTable/pagination/PaginationStream.scss index bf9ead04..acff7cfd 100644 --- a/src/components/tables/DataTable/pagination/PaginationStream.scss +++ b/src/components/tables/DataTable/pagination/PaginationStream.scss @@ -2,8 +2,8 @@ |* 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 bkl; -@use '../../../../style/mixins.scss' as mixins; +// @use '../../../../style/variables.scss' as bkl; +// @use '../../../../style/mixins.scss' as mixins; @use './Pagination.scss'; diff --git a/src/components/tables/DataTable/pagination/PaginationStream.tsx b/src/components/tables/DataTable/pagination/PaginationStream.tsx index d1fbd004..110b289b 100644 --- a/src/components/tables/DataTable/pagination/PaginationStream.tsx +++ b/src/components/tables/DataTable/pagination/PaginationStream.tsx @@ -3,15 +3,15 @@ |* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import cx from 'classnames'; -import * as React from 'react'; +import type * as React from 'react'; -import { SpriteIcon as Icon } from '../../../icons/Icon'; -import { Button } from '../../../buttons/Button'; +import { Icon } from '../../../graphics/Icon/Icon.tsx'; +import { Button } from '../../../actions/Button/Button.tsx'; -import { PageSizeOption, PaginationSizeSelector } from './PaginationSizeSelector'; -import { useTable } from '../DataTableContext'; +import { type PageSizeOption, PaginationSizeSelector } from './PaginationSizeSelector.tsx'; +import { useTable } from '../DataTableContext.tsx'; -import './PaginationStream.scss'; +// import './PaginationStream.scss'; type IconDoubleChevronLeftProps = React.ComponentPropsWithoutRef<'span'> & { @@ -20,10 +20,10 @@ type IconDoubleChevronLeftProps = React.ComponentPropsWithoutRef<'span'> & { const IconDoubleChevronLeft = ({ iconProps = {}, ...props }: IconDoubleChevronLeftProps) => { return ( - - @@ -38,27 +38,30 @@ export const PaginationStreamPager = ({ pageSizeOptions }: PaginationStreamPager return (
    - - -
    ); diff --git a/src/components/tables/DataTable/plugins/useCustomFilters.tsx b/src/components/tables/DataTable/plugins/useCustomFilters.tsx index c74c5274..0e5266ef 100644 --- a/src/components/tables/DataTable/plugins/useCustomFilters.tsx +++ b/src/components/tables/DataTable/plugins/useCustomFilters.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import * as ReactTable from 'react-table'; -import { type FilterQuery } from '../../MultiSearch/MultiSearch.tsx'; +import type { FilterQuery } from '../../MultiSearch/filterQuery.ts'; // Actions diff --git a/src/components/tables/DataTable/plugins/useRowSelectColumn.scss b/src/components/tables/DataTable/plugins/useRowSelectColumn.scss index ef3aec8b..b77f469e 100644 --- a/src/components/tables/DataTable/plugins/useRowSelectColumn.scss +++ b/src/components/tables/DataTable/plugins/useRowSelectColumn.scss @@ -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/. */ -@use '../../../../style/variables.scss' as bkl; +// @use '../../../../style/variables.scss' as bkl; .bkl-data-table-row-select { width: bkl.$sizing-7 + bkl.$sizing-1; diff --git a/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx b/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx index be78f0f0..71061e83 100644 --- a/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx +++ b/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx @@ -3,11 +3,11 @@ |* 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 * as ReactTable from 'react-table'; import { Checkbox } from '../../../forms/controls/Checkbox/Checkbox.tsx'; -import './useRowSelectColumn.scss'; +// import './useRowSelectColumn.scss'; // `react-table` plugin for row selection column. Note: depends on `react-table`'s `useRowSelect` plugin. diff --git a/src/components/tables/DataTable/table/DataTable.scss b/src/components/tables/DataTable/table/DataTable.scss index a8307cbd..638471ee 100644 --- a/src/components/tables/DataTable/table/DataTable.scss +++ b/src/components/tables/DataTable/table/DataTable.scss @@ -2,8 +2,8 @@ |* 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 bkl; -@use '../../../../style/mixins.scss' as mixins; +// @use '../../../../style/variables.scss' as bkl; +// @use '../../../../style/mixins.scss' as mixins; .bkl-data-table { diff --git a/src/components/tables/DataTable/table/DataTable.tsx b/src/components/tables/DataTable/table/DataTable.tsx index dada8647..673e428f 100644 --- a/src/components/tables/DataTable/table/DataTable.tsx +++ b/src/components/tables/DataTable/table/DataTable.tsx @@ -2,9 +2,9 @@ |* 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 React from 'react'; import { classNames as cx, type ClassNameArgument, type ComponentProps } from '../../../../util/componentUtil.ts'; -import * as ReactTable from 'react-table'; +import type * as ReactTable from 'react-table'; import { Icon } from '../../../graphics/Icon/Icon.tsx'; diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.scss b/src/components/tables/DataTable/table/DataTablePlaceholder.scss index 76e7a622..936f962b 100644 --- a/src/components/tables/DataTable/table/DataTablePlaceholder.scss +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.scss @@ -2,8 +2,8 @@ |* 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 bkl; -@use '../../../../style/mixins.scss' as mixins; +// @use '../../../../style/variables.scss' as bkl; +// @use '../../../../style/mixins.scss' as mixins; .bk-table-placeholder { diff --git a/src/components/tables/MultiSearch/MultiSearch.stories.tsx b/src/components/tables/MultiSearch/MultiSearch.stories.tsx index 1fa02cc9..1fc540ab 100644 --- a/src/components/tables/MultiSearch/MultiSearch.stories.tsx +++ b/src/components/tables/MultiSearch/MultiSearch.stories.tsx @@ -5,25 +5,20 @@ import { getDay as dateGetDay, startOfDay as dateStartOfDay, endOfDay as dateEndOfDay, sub as dateSub } from 'date-fns'; import * as React from 'react'; -import * as StorybookKnobs from '@storybook/addon-knobs'; -import { StoryMetadata } from '../../../types/storyMetadata'; - -import { Panel } from '../../../components/containers/panel/Panel'; -import * as MultiSearch from './MultiSearch'; +import type * as FQ from './filterQuery.ts'; +import * as MultiSearch from './MultiSearch.tsx'; export default { - title: 'Prefab/Forms/MultiSearch', - decorators: [ - StorybookKnobs.withKnobs, - renderStory => {renderStory()}, - ], - component: MultiSearch, -} as StoryMetadata; - + component: MultiSearch, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, +}; export const Standard = () => { - const severityFieldSpec: MultiSearch.EnumFieldSpec = { + const severityFieldSpec: FQ.EnumFieldSpec = { type: 'enum', operators: ['$eq', '$ne', '$in', '$nin'], label: 'Severity', @@ -35,7 +30,7 @@ export const Standard = () => { }, }; - const keyOpsFieldSpec: MultiSearch.ArrayFieldSpec = { + const keyOpsFieldSpec: FQ.ArrayFieldSpec = { type: 'array', operators: ['$eq', '$ne', '$any', '$all'], label: 'Key Ops', @@ -56,21 +51,21 @@ export const Standard = () => { }, }; - const initiatorFieldSpec: MultiSearch.TextFieldSpec = { + const initiatorFieldSpec: FQ.TextFieldSpec = { type: 'text', operators: ['$text'], label: 'Initiator', placeholder: 'Search initiator', }; - const countFieldSpec: MultiSearch.Field = { + const countFieldSpec: FQ.Field = { type: 'number', operators: ['$eq', '$lt', '$lte', '$gt', '$gte', '$ne'], label: 'Count', placeholder: 'Search ip-address', }; - const customAttributesFieldSpec: MultiSearch.DictionaryFieldSpec = { + const customAttributesFieldSpec: FQ.DictionaryFieldSpec = { type: 'dictionary', operators: ['$all'], label: 'Custom Attributes', @@ -80,7 +75,7 @@ export const Standard = () => { }, }; - const createdAtFieldSpec: MultiSearch.DateTimeFieldSpec = { + const createdAtFieldSpec: FQ.DateTimeFieldSpec = { type: 'datetime', operators: ['$gt', '$range'], label: 'Created', @@ -107,9 +102,9 @@ export const Standard = () => { }, }]; - const [filters, setFilters] = React.useState(defaultFilters); + const [filters, setFilters] = React.useState(defaultFilters); - const query = (filter: MultiSearch.FilterQuery) => setFilters(filter); + const query = (filter: FQ.FilterQuery) => setFilters(filter); return ( @@ -117,7 +112,7 @@ export const Standard = () => { }; export const WithValidation = () => { - const uuidFieldSpec: MultiSearch.TextFieldSpec = { + const uuidFieldSpec: FQ.TextFieldSpec = { type: 'text', operators: ['$text'], label: 'UUID', @@ -131,7 +126,7 @@ export const WithValidation = () => { }, }; - const severityFieldSpec: MultiSearch.EnumFieldSpec = { + const severityFieldSpec: FQ.EnumFieldSpec = { type: 'enum', operators: ['$eq', '$ne', '$in', '$nin'], label: 'Severity', @@ -150,7 +145,7 @@ export const WithValidation = () => { }, }; - const keyOpsFieldSpec: MultiSearch.ArrayFieldSpec = { + const keyOpsFieldSpec: FQ.ArrayFieldSpec = { type: 'array', operators: ['$eq', '$ne', '$any', '$all'], label: 'Key Ops', @@ -178,7 +173,7 @@ export const WithValidation = () => { }, }; - const countFieldSpec: MultiSearch.Field = { + const countFieldSpec: FQ.Field = { type: 'number', operators: ['$eq', '$lt', '$lte', '$gt', '$gte', '$ne'], label: 'Count', @@ -192,7 +187,7 @@ export const WithValidation = () => { }, }; - const customAttributesFieldSpec: MultiSearch.DictionaryFieldSpec = { + const customAttributesFieldSpec: FQ.DictionaryFieldSpec = { type: 'dictionary', operators: ['$all'], label: 'Custom attributes', @@ -209,7 +204,7 @@ export const WithValidation = () => { }, }; - const createdAtFieldSpec: MultiSearch.DateTimeFieldSpec = { + const createdAtFieldSpec: FQ.DateTimeFieldSpec = { type: 'datetime', operators: ['$gt', '$range'], label: 'Created', @@ -231,9 +226,9 @@ export const WithValidation = () => { createdAt: createdAtFieldSpec, }; - const [filters, setFilters] = React.useState([]); + const [filters, setFilters] = React.useState([]); - const query = (filter: MultiSearch.FilterQuery) => setFilters(filter); + const query = (filter: FQ.FilterQuery) => setFilters(filter); return ( diff --git a/src/components/tables/MultiSearch/MultiSearch.tsx b/src/components/tables/MultiSearch/MultiSearch.tsx index 8bc608c1..a3a3f54c 100644 --- a/src/components/tables/MultiSearch/MultiSearch.tsx +++ b/src/components/tables/MultiSearch/MultiSearch.tsx @@ -18,7 +18,7 @@ import { 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 * 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'; @@ -28,8 +28,9 @@ 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 { DateTimePicker } from '../../forms/datetime/DateTimePicker.tsx'; +// import * as Dropdown from '../../overlays/dropdown/Dropdown.tsx'; +import { DropdownMenu } from '../../overlays/DropdownMenu/DropdownMenu.tsx'; +// import { DateTimePicker } from '../../forms/datetime/DateTimePicker.tsx'; import * as FQ from './filterQuery.ts'; @@ -176,8 +177,8 @@ export const Filters = (props: FiltersProps) => { onRemoveAllFilters, } = props; - const renderDateTimeFilter = (filter: FieldQuery, index: number) => { - const { fieldName, operatorSymbol, operand } = decodeFieldQuery(filter, fields); + 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 = ':'; @@ -194,7 +195,7 @@ export const Filters = (props: FiltersProps) => { if (field && field.type === 'datetime') { if (operatorSymbol === 'Range') { - if (isRangeOperationValue(operand)) { + if (FQ.isRangeOperationValue(operand)) { const startDateTime = dateFormat(operand[0] * 1000, 'MMMM do yyyy HH:mm'); const endDateTime = dateFormat(operand[1] * 1000, 'MMMM do yyyy HH:mm'); operandLabel = { from: startDateTime, to: endDateTime }; @@ -233,7 +234,7 @@ export const Filters = (props: FiltersProps) => { }; const renderArrayFilter = (filter: FQ.FieldQuery, index: number) => { - const { fieldName, operatorSymbol, operand, subOperatorSymbol = '' } = decodeFieldQuery(filter, fields); + 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 : ''; @@ -274,14 +275,15 @@ export const Filters = (props: FiltersProps) => { ); }; - const renderFilter = (filter: FieldQuery, index: number) => { - const { fieldName, operatorSymbol, operand } = decodeFieldQuery(filter, fields); + 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); - } else if (field.type === 'array') { + } + if (field.type === 'array') { return renderArrayFilter(filter, index); } } @@ -334,6 +336,7 @@ export const Filters = (props: FiltersProps) => { return filters.length > 0 && (
    role="button" tabIndex={0} className="clear-all" @@ -365,7 +368,7 @@ export const Filters = (props: FiltersProps) => { // Suggestions dropdown // -const SuggestionItem = Dropdown.Item; +const SuggestionItem = DropdownMenu.Action; export type SuggestionProps = Omit, 'children'> & { children: React.ReactNode | ((props: { close: () => void }) => React.ReactNode), @@ -375,7 +378,7 @@ export type SuggestionProps = Omit, 'children'> & { primary?: undefined | boolean, secondary?: undefined | boolean, basic?: undefined | boolean, - popperOptions?: undefined | Dropdown.PopperOptions, + // popperOptions?: undefined | Dropdown.PopperOptions, onOutsideClick?: undefined | (() => void), containerRef?: undefined | React.RefObject, }; @@ -389,7 +392,7 @@ export const Suggestions = (props: SuggestionProps) => { basic = false, children = '', elementRef, - popperOptions = {}, + // popperOptions = {}, onOutsideClick, containerRef, } = props; @@ -471,7 +474,7 @@ export const Suggestions = (props: SuggestionProps) => { }; export type SearchInputProps = ComponentProps & { - fields: Fields, + fields: FQ.Fields, fieldQueryBuffer: FieldQueryBuffer, inputRef: React.RefObject, }; @@ -549,6 +552,7 @@ export const SearchInput = (props: SearchInputProps) => { return (
    role="button" tabIndex={0} className={cx('bkl-search-input', className, { 'bkl-search-input--active': isFocused })} @@ -577,8 +581,8 @@ export const SearchInput = (props: SearchInputProps) => { type FieldsDropdownProps = { inputRef?: React.RefObject, isActive?: boolean, - fields?: Fields, - popperOptions?: Dropdown.PopperOptions, + fields?: FQ.Fields, + // popperOptions?: Dropdown.PopperOptions, onClick: (fieldName?: string) => void, onOutsideClick?: () => void, }; @@ -600,7 +604,7 @@ const FieldsDropdown = (props: FieldsDropdownProps) => { return ( { type AlternativesDropdownProps = { inputRef?: React.RefObject, isActive?: boolean, - operators?: EnumFieldOperator[] | ArrayFieldOperator[], - alternatives?: Alternatives, - popperOptions?: Dropdown.PopperOptions, - selectedOperator: Operator, + operators?: FQ.EnumFieldOperator[] | FQ.ArrayFieldOperator[], + alternatives?: FQ.Alternatives, + // popperOptions?: Dropdown.PopperOptions, + selectedOperator: FQ.Operator, onChange: (value: Primitive[]) => void, onOutsideClick?: () => void, - validator?: ArrayValidator, + validator?: FQ.ArrayValidator, }; const AlternativesDropdown = (props: AlternativesDropdownProps) => { @@ -722,7 +726,7 @@ const AlternativesDropdown = (props: AlternativesDropdownProps) => { { type DateTimeDropdownProps = { inputRef?: React.RefObject, isActive?: boolean, - popperOptions?: Dropdown.PopperOptions, + // popperOptions?: Dropdown.PopperOptions, onChange: (value: number | [number, number]) => void, onOutsideClick?: () => void, maxDate?: Date | number, minDate?: Date | number, - selectedDate?: SelectedDate, + selectedDate?: FQ.SelectedDate, canSelectDateTimeRange?: boolean, - validator?: DateTimeValidator, + validator?: FQ.DateTimeValidator, }; const DateTimeDropdown = (props: DateTimeDropdownProps) => { @@ -766,7 +770,7 @@ const DateTimeDropdown = (props: DateTimeDropdownProps) => { return !!(date && typeof date === 'number' || date instanceof Date); }; - const isValidSelectedDate = (selectedDate: SelectedDate | undefined) => { + const isValidSelectedDate = (selectedDate: FQ.SelectedDate | undefined) => { if (Array.isArray(selectedDate) && selectedDate.length === 2) { return isValidDateParamType(selectedDate[0]) && isValidDateParamType(selectedDate[1]); } @@ -780,7 +784,7 @@ const DateTimeDropdown = (props: DateTimeDropdownProps) => { : date; }; - const initDateTime = (selectedDate: SelectedDate | undefined, range: 'start' | 'end') => { + const initDateTime = (selectedDate: FQ.SelectedDate | undefined, range: 'start' | 'end') => { const defaultDate = setDate(new Date(), { seconds: 0, milliseconds: 0 }); if (!selectedDate) { return defaultDate; @@ -886,28 +890,27 @@ const DateTimeDropdown = (props: DateTimeDropdownProps) => { <>
    Start Date
    - + /> */}
    End Date
    - + /> */}
    - <> - {!dateTimeRangeValidation.isValid + {!dateTimeRangeValidation.isValid && dateTimeRangeValidation.message && ( @@ -915,7 +918,6 @@ const DateTimeDropdown = (props: DateTimeDropdownProps) => { ) } -
    - - + + + + } /> } diff --git a/src/components/tables/DataTable/DataTableLazy.tsx b/src/components/tables/DataTable/DataTableLazy.tsx index c95ef519..b6cd3921 100644 --- a/src/components/tables/DataTable/DataTableLazy.tsx +++ b/src/components/tables/DataTable/DataTableLazy.tsx @@ -7,6 +7,7 @@ import { classNames as cx, ClassNameArgument } from '../../../util/componentUtil import { useEffectAsync } from '../util/hooks.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'; @@ -16,12 +17,14 @@ import { DataTablePlaceholderError } from './table/DataTablePlaceholder'; import { DataTableAsync } from './table/DataTable'; import { Icon } from '../../graphics/Icon/Icon.tsx'; -// import './DataTableLazy.scss'; +import './DataTableLazy.scss'; export * from './DataTableContext'; 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 { @@ -270,8 +273,8 @@ export const DataTableLazy = ({ className, footer, ...propsRest }: DataTableLazy return ( { reload(); }}> - Retry - + + + } /> } diff --git a/src/components/tables/DataTable/DataTableStream.scss b/src/components/tables/DataTable/DataTableStream.scss index d0f2b4a5..3b931d30 100644 --- a/src/components/tables/DataTable/DataTableStream.scss +++ b/src/components/tables/DataTable/DataTableStream.scss @@ -6,6 +6,6 @@ @use './DataTableLazy.scss' as dataTableLazy; -.bkl-data-table-stream--loading { - @include dataTableLazy.data-table-loading(); +.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 index bef5994f..e6d5ef79 100644 --- a/src/components/tables/DataTable/DataTableStream.stories.tsx +++ b/src/components/tables/DataTable/DataTableStream.stories.tsx @@ -97,7 +97,7 @@ const DataTableStreamTemplate = (props : dataTeableLazyTemplateProps) => { ); return ( - + { > + + + + } + /> + } {...props} /> diff --git a/src/components/tables/DataTable/DataTableStream.tsx b/src/components/tables/DataTable/DataTableStream.tsx index fc9c9e57..fbd84a92 100644 --- a/src/components/tables/DataTable/DataTableStream.tsx +++ b/src/components/tables/DataTable/DataTableStream.tsx @@ -21,6 +21,7 @@ import { useCustomFilters } from './plugins/useCustomFilters'; // import './DataTableStream.scss'; import { Spinner } from '../../graphics/Spinner/Spinner.tsx'; import { Button } from '../../actions/Button/Button.tsx'; +import { PlaceholderEmptyAction } from '../../graphics/PlaceholderEmpty/PlaceholderEmpty.tsx'; export * from './DataTableContext'; @@ -30,6 +31,7 @@ export { DataTablePlaceholderEmpty, DataTablePlaceholderError, DataTablePlaceholderEndOfTable, + PlaceholderEmptyAction, } from './table/DataTablePlaceholder'; @@ -507,8 +509,8 @@ export const DataTableStream = ({ return ( { reload(); }}> - Retry - + + + } /> } diff --git a/src/components/tables/DataTable/pagination/Pagination.scss b/src/components/tables/DataTable/pagination/Pagination.scss index f5c1543d..ac27edc0 100644 --- a/src/components/tables/DataTable/pagination/Pagination.scss +++ b/src/components/tables/DataTable/pagination/Pagination.scss @@ -2,76 +2,54 @@ |* 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 bkl; -// @use '../../../../style/mixins.scss' as mixins; +@use '../../../../styling/defs.scss' as bk; .pagination { display: flex; align-items: center; justify-content: flex-end; - gap: bkl.$sizing-m; - - color: bkl.$neutral-color-3; - @include mixins.font($family: bkl.$font-family-display, $weight: bkl.$font-weight-regular); - font-size: bkl.$font-size-s; + 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: 1rem; - - .pager__nav { - font-size: 0.6rem; - - @include mixins.circle(2.4rem); - padding: bkl.$sizing-2; - background: bkl.$accent-color; - color: bkl.$light-color-1; - + gap: bk.$spacing-1; + padding-left: bk.$spacing-6; + border-left: bk.$size-1 solid bk.$theme-pagination-border-default; + + .pager__nav { &:not(.disabled) { cursor: pointer; } &.disabled { - background: rgba(bkl.$dark-color-2, 0.34); + opacity: 0.34; } } - - .pager__indices { - display: flex; - align-items: center; - gap: 0.5rem; - - li { - user-select: none; - - padding: 3px 7px; - color: #4D758C; - - &:not(.pager__indices__separator) { - border-radius: bkl.$border-radius-s; - - transition: background-color 150ms ease-in; - &:hover { - background-color: rgba(bkl.$neutral-color-4, 0.5); - } - &.active { - background-color: bkl.$neutral-color-4; - } - &:not(.active) { - cursor: pointer; - } - } - &.pager__indices__separator { - padding-inline: 0; - } - } + } + .pagination__page-input{ + text-align: center; + border: bk.$size-1 solid bk.$theme-pagination-border-default; + border-radius: bk.$size-2; + appearance: textfield; + min-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 (max-width: 1100px) { +@media only screen and (max-width <= 1100px) { .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 index 307db42c..ff922f10 100644 --- a/src/components/tables/DataTable/pagination/Pagination.tsx +++ b/src/components/tables/DataTable/pagination/Pagination.tsx @@ -2,86 +2,27 @@ |* 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 cx from 'classnames'; -import * as React from 'react'; -import { joinElements } from '../../../../util/componentUtil.ts'; +import cx from "classnames"; -import { Icon } from '../../../graphics/Icon/Icon.tsx'; +import { Icon } from "../../../graphics/Icon/Icon.tsx"; +import { Input } from "../../../forms/controls/Input/Input.tsx"; -import { type PageSizeOption, PaginationSizeSelector } from './PaginationSizeSelector.tsx'; -import { useTable } from '../DataTableContext.tsx'; +import { + type PageSizeOption, + PaginationSizeSelector, +} from "./PaginationSizeSelector.tsx"; +import { useTable } from "../DataTableContext.tsx"; -// import './Pagination.scss'; +import "./Pagination.scss"; -type PageOptionsSegment = Array; // Consecutive list of page indices (e.g. `[5, 6, 7]`) -type PageOptions = Array; // List of segments (e.g. `[[1], [49, 50, 51], [100]]`) -const combineSegments = (segment1: PageOptionsSegment, segment2: PageOptionsSegment): PageOptions => { - if (segment1.length === 0 || segment2.length === 0) { - return [[...segment1, ...segment2]]; - } - - const gapLeft = segment1[segment1.length - 1]; // Last element of `segment1` - const gapRight = segment2[0]; // First element of `segment2` - - // Ensure `gapLeft` and `gapRight` are defined before proceeding - if (gapLeft === undefined || gapRight === undefined) { - return [segment1, segment2]; - } - - const gapSize = gapRight - gapLeft - 1; // Calculate the gap - if (gapSize > 1) { - // If there is a gap between the segments larger than one, leave unmerged - return [segment1, segment2]; - } if (gapSize === 1) { - // If the gap is 1 (i.e. there is only one element "missing" in between), fill it in and merge - // Motivation: there will be a separator between gaps (e.g. `4 5 ... 7 8`), so if there is only element in between, - // then it makes sense to replace the separator with the missing element explicitly. - return [[...segment1, gapLeft + 1, ...segment2]]; - } - // If there is no gap, combine the two segments (removing any overlapping elements) - return [[...segment1, ...segment2.filter((pageIndex) => pageIndex > gapLeft)]]; -}; -const getPageOptions = ({ pageCount, pageIndex }: { pageCount: number, pageIndex: number }): PageOptions => { - const pageIndexFirst = 0; - const pageIndexLast = pageCount - 1; - - // Basic template for page options - const template = [ - [pageIndexFirst, pageIndexFirst + 1], // First two pages - [pageIndex - 1, pageIndex, pageIndex + 1], // Current page, plus immediate predecessor and successor - [pageIndexLast - 1, pageIndexLast], // Last two pages - ]; - - return template.reduce( - (pageOptions: PageOptions, segmentTemplate: PageOptionsSegment): PageOptions => { - // Filter out any invalid page indices from the template - const segment: PageOptionsSegment = segmentTemplate.filter((pageIndex: number) => { - return pageIndex >= pageIndexFirst && pageIndex <= pageIndexLast; - }); - - if (pageOptions.length === 0) { - return [segment]; - } - - // Split `pageOptions` into its last segment, and everything before: `[...pageOptionsBase, segmentPrior]` - const pageOptionsBase: PageOptions = pageOptions.slice(0, -1); - const segmentPrior: PageOptionsSegment = pageOptions.slice(-1)[0] || []; - - // Attempt to combine `segmentPrior` and `segment` into one consecutive segment (if there's no gap in between) - return [...pageOptionsBase, ...combineSegments(segmentPrior, segment)]; - }, - [], - ); -}; - type PaginationProps = { - pageSizeOptions?: Array, + pageSizeOptions?: Array; }; export const Pagination = ({ pageSizeOptions }: PaginationProps) => { - const { table } = useTable(); - - /* + const { table } = useTable(); + + /* Available pagination state: - table.state.pageIndex - table.state.pageSize @@ -94,56 +35,55 @@ export const Pagination = ({ pageSizeOptions }: PaginationProps) => { - table.previousPage - table.setPageSize */ - - const pageCount = Math.max(table.pageCount, 1); // Note: for an empty table `react-table` will return pageCount = 0 - const pageIndex = table.state.pageIndex; - const pageOptions: PageOptions = getPageOptions({ pageCount, pageIndex }); - - return ( -
    - - -
    - - -
      - {joinElements( // Join the segments together with separator
    • elements inserted in between -
    • , - pageOptions - .map((pageOptionsSegment, segmentIndex) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: - - {pageOptionsSegment.map((pageIndex) => ( -
    • { - table.gotoPage(pageIndex); - }} - onKeyDown={() => { - table.gotoPage(pageIndex); - }} - > - {pageIndex + 1} -
    • - ))} -
      - )) - )} -
    - - -
    -
    - ); + + return ( +
    + + +
    + table.gotoPage(0)} + /> +
    + + + table.gotoPage(Number.parseInt(event.target.value) - 1) + } + /> + of {table.pageCount} + +
    + table.gotoPage(table.pageCount - 1)} + /> +
    +
    + ); }; diff --git a/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss b/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss index 1e8f932f..782cf7d8 100644 --- a/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss +++ b/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss @@ -2,38 +2,37 @@ |* 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 bkl; -// @use '../../../../style/mixins.scss' as mixins; +@use '../../../../styling/defs.scss' as bk; .page-size-selector { flex: none; - display: flex; align-items: center; - + gap: bk.$spacing-1; + .page-size-selector__page-size { user-select: none; flex: none; - - padding: bkl.$sizing-xs; - display: flex; align-items: center; - gap: bkl.$sizing-xs; - - @include mixins.font($family: bkl.$font-family-display, $weight: bkl.$font-weight-regular); - + .icon-caret { width: 0.8em; } } - - @at-root .page-size-selector__selector { - min-width: 6rem; - .bkl-dropdown__menu > .bkl-dropdown__menu-item { - padding-block: bkl.$sizing-xxs; - text-align: right; + + .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; + } } -} +} \ No newline at end of file diff --git a/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx b/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx index 3867b5f0..fcfd83cd 100644 --- a/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx +++ b/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx @@ -10,7 +10,7 @@ import { DropdownMenuProvider } from '../../../overlays/DropdownMenu/DropdownMen import { useTable } from '../DataTableContext.tsx'; -// import './PaginationSizeSelector.scss'; +import './PaginationSizeSelector.scss'; export type PageSizeOption = number; @@ -21,7 +21,7 @@ type PaginationSizeSelectorProps = { pageSizeLabel?: string | undefined, }; export const PaginationSizeSelector = (props: PaginationSizeSelectorProps) => { - const { pageSizeOptions = defaultPageSizeOptions, pageSizeLabel = 'Items per page' } = props; + const { pageSizeOptions = defaultPageSizeOptions, pageSizeLabel = 'Rows per page' } = props; const { table } = useTable(); @@ -30,6 +30,7 @@ export const PaginationSizeSelector = (props: PaginationSizeSelectorProps) => { {pageSizeLabel}: ( { > {({ props }) => ( -
    ); diff --git a/src/components/tables/DataTable/plugins/useRowSelectColumn.scss b/src/components/tables/DataTable/plugins/useRowSelectColumn.scss index b77f469e..9f118ff9 100644 --- a/src/components/tables/DataTable/plugins/useRowSelectColumn.scss +++ b/src/components/tables/DataTable/plugins/useRowSelectColumn.scss @@ -2,17 +2,9 @@ |* 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 bkl; +@use '../../../../styling/defs.scss' as bk; -.bkl-data-table-row-select { - width: bkl.$sizing-7 + bkl.$sizing-1; - max-width: bkl.$sizing-7 + bkl.$sizing-1; - - .bkl-data-table-row-select__header { - margin-bottom: bkl.$sizing-m; - } - - .bkl-data-table-row-select__cell { - margin-bottom: bkl.$sizing-m; - } +.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 index 71061e83..9d61d648 100644 --- a/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx +++ b/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx @@ -7,7 +7,7 @@ import type * as ReactTable from 'react-table'; import { Checkbox } from '../../../forms/controls/Checkbox/Checkbox.tsx'; -// import './useRowSelectColumn.scss'; +import './useRowSelectColumn.scss'; // `react-table` plugin for row selection column. Note: depends on `react-table`'s `useRowSelect` plugin. @@ -16,11 +16,11 @@ export const useRowSelectColumn = (hooks: ReactTable.Hooks) hooks.visibleColumns.push(columns => [ { id: 'selection', - className: 'bkl-data-table-row-select', + className: 'bk-data-table-row-select', Header: ({ getToggleAllPageRowsSelectedProps }) => { const { checked, onChange } = getToggleAllPageRowsSelectedProps(); return ( -
    +
    ); @@ -28,7 +28,7 @@ export const useRowSelectColumn = (hooks: ReactTable.Hooks) Cell: ({ row }: ReactTable.CellProps) => { const { checked, onChange } = row.getToggleRowSelectedProps(); return ( -
    +
    ); diff --git a/src/components/tables/DataTable/table/DataTable.scss b/src/components/tables/DataTable/table/DataTable.scss index 638471ee..1bb22e59 100644 --- a/src/components/tables/DataTable/table/DataTable.scss +++ b/src/components/tables/DataTable/table/DataTable.scss @@ -2,125 +2,94 @@ |* 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 bkl; -// @use '../../../../style/mixins.scss' as mixins; +@use '../../../../styling/defs.scss' as bk; -.bkl-data-table { +.bk-data-table { + @include bk.component-base(bk-data-table); width: 100%; - overflow-x: auto; - - table.bkl-data-table__table { + + table.bk-data-table__table { width: 100%; table-layout: fixed; border-collapse: collapse; - + th { - padding: 1.5rem 0.5rem; - + 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: 1.5rem 0.5rem; - + 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 bkl.$neutral-color-1; - + border-bottom: 1px solid bk.$theme-rule-default; + th { cursor: default; padding-bottom: 1rem; - - color: bkl.$neutral-color-3; - @include mixins.font($family: bkl.$font-family-display, $weight: bkl.$font-weight-semibold); - font-size: bkl.$font-size-xs; + + 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: baseline; - + align-items: center; + gap: bk.$spacing-1; .sort-indicator { - align-self: baseline; // Override .bkl-icon - margin-left: 0.8rem; - width: 1rem; - + width: bk.$spacing-4; + &.sort-indicator--inactive { - opacity: 0.3; + opacity: 0; } - - transition: transform 240ms ease-in-out, opacity 120ms ease-in-out; + + 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(.bkl-data-table__placeholder) { + tr:not(.bk-data-table__placeholder) { &:not(:last-of-type) { - border-bottom: 1px solid bkl.$neutral-color-1; - } - - &:hover { - background-color: bkl.$light-color-2; - - // Add rounded edges to the sides of the table row - border-radius: bkl.$border-radius-s; - box-shadow: -0.7rem 0 0 0 bkl.$light-color-2, 0.7rem 0 0 0 bkl.$light-color-2; - - // Workaround for WebKit, where box-shadow on `tr` does not work: - // https://stackoverflow.com/questions/5972525/table-row-box-shadow-on-hover-webkit - // Note: the following does not seem to work in Chrome, so we need both `tr` and `td` solutions - > td:first-child { - border-top-left-radius: bkl.$border-radius-s; - border-bottom-left-radius: bkl.$border-radius-s; - box-shadow: -0.7rem 0 0 0 bkl.$light-color-2; - } - > td:last-child { - border-top-right-radius: bkl.$border-radius-s; - border-bottom-right-radius: bkl.$border-radius-s; - box-shadow: 0.7rem 0 0 0 bkl.$light-color-2; - } + border-bottom: 1px solid bk.$theme-rule-default; } - - // Item cell - > td { - font-size: bkl.$font-size-s; - - .text-minor { - color: bkl.$brand-color; - font-size: bkl.$font-size-xs; - } - - :any-link:not(.bkl-link--plain) { - color: bkl.$dark-color-1; - - &:hover { - text-decoration: underline; - } - } - - .bkl-data-table__item__name { - @include mixins.font($family: bkl.$font-family-display, $weight: bkl.$font-weight-medium); + + &:hover, &.selected { + background-color: bk.$theme-table-background-hover; + + // Item cell + >td { + font-size: bk.$font-size-m; } - @at-root .bkl-data-table tr:hover .bkl-data-table__item__name { text-decoration: underline; } } } } - + tfoot { td { overflow: visible; // Allow overflow due to dropdown menu @@ -129,54 +98,51 @@ } } -@media only screen and (width <= 1100px) { - table.bkl-data-table { +@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 { + + >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(.bkl-data-table__placeholder) { + + tr:not(.bk-data-table__placeholder) { margin: 1.5rem 0; padding: 1.5rem; - border-radius: bkl.$border-radius-ms; - background: bkl.$light-color-2; - box-shadow: 0.1rem 0.1rem 0.1rem rgba(bkl.$brand-color-dark-1, 0.15); - + &:not(:last-of-type) { border-bottom: none; } - - &:hover { - box-shadow: 0.1rem 0.1rem 0.1rem rgba(bkl.$brand-color-dark-1, 0.15); // Override desktop `:hover` + + + >td:empty { + display: none; } - - > td:empty { display: none; } } } } diff --git a/src/components/tables/DataTable/table/DataTable.tsx b/src/components/tables/DataTable/table/DataTable.tsx index 673e428f..7238534f 100644 --- a/src/components/tables/DataTable/table/DataTable.tsx +++ b/src/components/tables/DataTable/table/DataTable.tsx @@ -6,6 +6,7 @@ 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 { @@ -15,7 +16,7 @@ import { } from './DataTablePlaceholder.tsx'; import type { DataTableStatus } from '../DataTableContext.tsx'; -//import './DataTable.scss'; +import './DataTable.scss'; // Note: `placeholder` is included in `table` props as part of "Standard HTML Attributes", but it's not actually a @@ -47,7 +48,7 @@ export const DataTable = (props: DataTableProps) => { {columnGroups} @@ -92,7 +93,7 @@ export const DataTable = (props: DataTableProps) => { {typeof placeholder !== 'undefined' && - + @@ -102,8 +103,8 @@ export const DataTable = (props: DataTableProps) => { table.prepareRow(row); const { key: rowKey, ...rowProps } = row.getRowProps(); return ( - - {/* + {/* + @@ -160,7 +161,7 @@ export const DataTableSync = (props: DataTableSyncProps) => } = props; const isEmpty = status.ready && props.table.page.length === 0; - + const scrollProps = useScroller({ scrollDirection: 'horizontal' }); const renderPlaceholder = (): React.ReactNode => { if (!status.ready) { return placeholderSkeleton; @@ -174,7 +175,8 @@ export const DataTableSync = (props: DataTableSyncProps) => // Note: the wrapper div isn't really necessary, but we include it for structural consistency with `DataTableAsync` return (
    (props: DataTableAsyncProps) 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) { @@ -230,7 +233,8 @@ export const DataTableAsync = (props: DataTableAsyncProps) return (
    {children} diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.scss b/src/components/tables/DataTable/table/DataTablePlaceholder.scss index 936f962b..748a50aa 100644 --- a/src/components/tables/DataTable/table/DataTablePlaceholder.scss +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.scss @@ -2,98 +2,64 @@ |* 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 bkl; -// @use '../../../../style/mixins.scss' as mixins; - +@use '../../../../styling/defs.scss' as bk; .bk-table-placeholder { - min-height: 40rem; - padding: bkl.$sizing-l; - - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - - color: bkl.$brand-color-dark-2; - @include mixins.font($family: bkl.$font-family-display, $weight: bkl.$font-weight-regular); - font-size: bkl.$font-size-l; - - .bk-table-placeholder__icon { - box-sizing: content-box; // Have padding increase the size rather than shrink the content - width: 1.8em; - height: 1.8em; - margin-bottom: bkl.$sizing-m; - padding: 1em; - } - - .bk-table-placeholder__message { - margin: 0; - } - - .bk-table-placeholder__actions { - margin: 0; - margin-top: bkl.$sizing-l; - - display: flex; - gap: bkl.$sizing-s; + @include bk.component-base(bk-table-placeholder); + + &:not(.bk-table-placeholder--skeleton) { + padding: bk.$spacing-14 bk.$spacing-2; + } - - &.bk-table-placeholder--error { - color: bkl.$status-color-error; - - .bk-table-placeholder--error__error-icon { - position: relative; - - .icon-cross { - position: absolute; - top: 1.4rem; - right: 1.6rem; - - @include mixins.circle($size: 1.8rem); - background: bkl.$status-color-error; - color: bkl.$light-color-1; - - border: 0.2rem solid bkl.$light-color-1; + + &.bk-table-placeholder--skeleton { + align-items: stretch; + justify-content: space-around; + gap: 1rem; + margin: -1 * bk.$spacing-5 -1 * bk.$spacing-2; + + >.skeleton-row { + display: block; + height: 1.5rem; + border-bottom: 1px solid bk.$theme-rule-default; + gap: 2 * bk.$spacing-5; + margin: bk.$spacing-1 0; + height: bk.$spacing-10; + display: flex; + align-items: center; + .skeleton-cell { + flex: 1; + &:after{ + content:" "; + background-color: bk.$theme-rule-default; + height: bk.$spacing-2; + border-radius: bk.$border-radius-xl; + display: block; + width: 60%; + } } } } } +// TODO: Figure this thing out +// .bk-table-row-placeholder { +// @include bk.component-base(bk-table-row-placeholder); -.bk-table-row-placeholder { - background: hsla(37, 91%, 55%, 0.04); - border: 1px solid #F5A623; - padding: bkl.$sizing-3 (bkl.$sizing-4 + bkl.$sizing-1); - height: 5.2rem; - display: flex; - align-items: center; +// background: hsla(37, 91%, 55%, 0.04); +// border: 1px solid #F5A623; +// padding: bk.$sizing-3 ( +// bk.$sizing-4 + bk.$sizing-1 +// ); +// height: 5.2rem; +// display: flex; +// align-items: center; - .bk-table-row-placeholder__icon { - color: #F5A623; - margin-right: bkl.$sizing-4 + bkl.$sizing-1; - } - - .bk-table-row-placeholder__message { - @include mixins.font( - $family: bkl.$font-family-body, - $weight: bkl.$font-weight-regular, - $size: bkl.$font-size-s, - ); - line-height: bkl.$line-height-4; - color: rgba(12, 12, 34, 0.87); - } -} +// .bk-table-row-placeholder__icon { +// color: #F5A623; +// margin-right: bk.$sizing-4 + bk.$sizing-1; +// } -.bk-table-placeholder.bk-table-placeholder--skeleton { - align-items: stretch; - justify-content: space-around; - gap: 1rem; - - > .skeleton-row { - display: block; - height: 1.5rem; - border-radius: bkl.$border-radius-ms; - - @include mixins.shimmer($baseColor: bkl.$neutral-color-4, $highlightColor: bkl.$light-color-2); - } -} +// .bk-table-row-placeholder__message { +// color: rgba(12, 12, 34, 0.87); +// } +// } \ No newline at end of file diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.tsx b/src/components/tables/DataTable/table/DataTablePlaceholder.tsx index 19eaefc0..7d94806b 100644 --- a/src/components/tables/DataTable/table/DataTablePlaceholder.tsx +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.tsx @@ -5,110 +5,61 @@ import * as React from 'react'; import { classNames as cx, type ClassNameArgument, type ComponentProps } from '../../../../util/componentUtil.ts'; import { type IconName, isIconName, Icon, type IconProps } from '../../../graphics/Icon/Icon.tsx'; +import { PlaceholderEmpty, type PlaceholderEmptyProps } from '../../../graphics/PlaceholderEmpty/PlaceholderEmpty.tsx'; +import { useTable } from '../DataTableContext.tsx'; -//import './DataTablePlaceholder.scss'; +export { + PlaceholderEmptyAction, +} from '../../../graphics/PlaceholderEmpty/PlaceholderEmpty.tsx'; - -type DataTablePlaceholderProps = ComponentProps<'div'> & { - icon?: IconName | React.ReactNode, - classNameIcon?: ClassNameArgument, - classNameMessage?: ClassNameArgument, - classNameActions?: ClassNameArgument, - placeholderMessage: React.ReactNode, - actions?: React.ReactNode, -}; -export const DataTablePlaceholder = (props: DataTablePlaceholderProps) => { - const { icon, classNameIcon, classNameMessage, classNameActions, placeholderMessage, actions, ...propsRest } = props; - - const decoration = React.useMemo(() => ({ type: 'background-circle' } as const), []); - const renderStandardIcon = (icon: IconProps['icon']): React.ReactNode => { - return ( - - ); - }; - - const renderIcon = (): React.ReactNode => { - if (typeof icon === 'string' && isIconName(icon)) { - return renderStandardIcon(icon); - } - return renderStandardIcon('file'); - }; - - return ( -
    - {renderIcon()} - -

    - {placeholderMessage} -

    - - {actions && -

    - {actions} -

    - } -
    - ); -}; +import './DataTablePlaceholder.scss'; // Loading skeleton (when there's no data to show yet) type DataTablePlaceholderSkeletonProps = { className?: ClassNameArgument }; export const DataTablePlaceholderSkeleton = (props: DataTablePlaceholderSkeletonProps) => { + // TODO: WIP for Skeleton loader (shadow per cell), maybe needs shimmer + const { table } = useTable(); return (
    {Array.from({ length: 6 }).map((_, index) => // biome-ignore lint/suspicious/noArrayIndexKey: no other unique identifier available - , + + {table.columns.map((_, index) => + // biome-ignore lint/suspicious/noArrayIndexKey: no other unique identifier available + + )} + , )}
    ); }; - // Empty table (ready but no data) -type DataTablePlaceholderEmptyProps = Omit & { +type DataTablePlaceholderEmptyProps = Omit & { // Make `placeholderMessage` optional - placeholderMessage?: DataTablePlaceholderProps['placeholderMessage'], + title?: PlaceholderEmptyProps['title'], }; export const DataTablePlaceholderEmpty = (props: DataTablePlaceholderEmptyProps) => { return ( - ); }; - -type DataTableErrorIconProps = Omit & { - icon?: IconProps['icon'], -}; -export const DataTableErrorIcon = (props: DataTableErrorIconProps) => { - const decoration = React.useMemo(() => ({ type: 'background-circle' } as const), []); - return ( -
    - - -
    - ); -}; - -type DataTablePlaceholderErrorProps = Omit & { +type DataTablePlaceholderErrorProps = Omit & { // Make `placeholderMessage` optional - placeholderMessage?: React.ComponentProps['placeholderMessage'], + title?: React.ComponentProps['title'], }; export const DataTablePlaceholderError = (props: DataTablePlaceholderErrorProps) => { return ( - } - placeholderMessage="Failed to load items" + ); }; @@ -127,7 +78,7 @@ export const DataTableRowPlaceholder = (props: DataTableRowPlaceholderProps) => const renderStandardIcon = (icon: IconProps['icon']): React.ReactNode => { return ( - + ); }; diff --git a/src/components/tables/MultiSearch/MultiSearch.scss b/src/components/tables/MultiSearch/MultiSearch.scss index 1ba904d2..bd627de6 100644 --- a/src/components/tables/MultiSearch/MultiSearch.scss +++ b/src/components/tables/MultiSearch/MultiSearch.scss @@ -6,7 +6,7 @@ @use '../../../components/overlays/dropdown/Dropdown.scss'; -.bkl-search-input { +.bk-search-input { position: relative; display: flex; flex: 1; @@ -16,57 +16,57 @@ border: 0.2rem solid $neutral-color-1; border-radius: $sizing-2; - &.bkl-search-input--active { + &.bk-search-input--active { background-color: $light-color-1; border-color: $accent-color-light-2; } - &:hover:not(.bkl-search-input--active) { + &:hover:not(.bk-search-input--active) { background-color: $light-color-1; border-color: rgba($accent-color-light-2, 0.33); outline: none; } - .bkl-input { - .bkl-input__input { + .bk-input { + .bk-input__input { background-color: transparent; border: none; } } - .bkl-search-input__search-icon, .bkl-search-input__search-key { + .bk-search-input__search-icon, .bk-search-input__search-key { padding: 1rem; padding-right: $sizing-none; } - .bkl-search-input__search-icon { + .bk-search-input__search-icon { width: $sizing-m; opacity: 0.5; color: #8AA1B0; } - .bkl-search-input__search-key { + .bk-search-input__search-key { font-size: $font-size-s; font-weight: $font-weight-light; line-height: $line-height-2; flex: 1 0 auto; } - .bkl-search-input__input { + .bk-search-input__input { width: 100%; } } -.bkl-multi-search__filters { +.bk-multi-search__filters { display: flex; margin-top: $sizing-s; - .bkl-multi-search__filters-wrapper { + .bk-multi-search__filters-wrapper { display: flex; flex-wrap: wrap; gap: $sizing-s; - .bkl-multi-search__filter { + .bk-multi-search__filter { .filter-operand { margin-left: $sizing-xxs; } @@ -78,7 +78,7 @@ } } - .bkl-multi-search__filter-actions { + .bk-multi-search__filter-actions { margin-left: auto; flex-shrink: 0; padding-left: $sizing-s; @@ -94,30 +94,30 @@ } } -.bkl-multi-search__operators { +.bk-multi-search__operators { .operator { display: flex; justify-content: center; } } -.bkl-multi-search__alternatives { - .bkl-multi-search__alternatives-group { - .bkl-checkbox { +.bk-multi-search__alternatives { + .bk-multi-search__alternatives-group { + .bk-checkbox { padding: $sizing-s; } } - .bkl-multi-search__alternatives-action { + .bk-multi-search__alternatives-action { padding-top: $sizing-s; display: flex; justify-content: center; } } -.bkl-multi-search__date-time { - .bkl-multi-search__date-time-group { - .bkl-multi-search__date-time-label { +.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; } @@ -125,7 +125,7 @@ padding: $sizing-s; } - .bkl-multi-search__date-time-action { + .bk-multi-search__date-time-action { padding-top: $sizing-s; display: flex; justify-content: center; @@ -133,20 +133,20 @@ } -.bkl-multi-search__suggested-keys { - .bkl-multi-search__suggested-key-input .bkl-input__input { +.bk-multi-search__suggested-keys { + .bk-multi-search__suggested-key-input .bk-input__input { width: auto; } } -.bkl-multi-search__error-msg, -.bkl-multi-search__dropdown-error-msg { +.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; } -.bkl-multi-search__dropdown-error-msg { +.bk-multi-search__dropdown-error-msg { padding: $sizing-s; } diff --git a/src/components/tables/MultiSearch/MultiSearch.tsx b/src/components/tables/MultiSearch/MultiSearch.tsx index 7087dc5d..5f84b0c4 100644 --- a/src/components/tables/MultiSearch/MultiSearch.tsx +++ b/src/components/tables/MultiSearch/MultiSearch.tsx @@ -230,7 +230,7 @@ export const Filters = (props: FiltersProps) => { return ( { onRemoveFilter?.(index); }} content={content} /> @@ -264,7 +264,7 @@ export const Filters = (props: FiltersProps) => { return ( { onRemoveFilter?.(index); }} content={ fieldNameLabel @@ -322,7 +322,7 @@ export const Filters = (props: FiltersProps) => { return ( { onRemoveFilter?.(index); }} content={ fieldNameLabel @@ -339,7 +339,7 @@ export const Filters = (props: FiltersProps) => { const renderActions = () => { return filters.length > 0 && ( -
    +
    role="button" @@ -359,8 +359,8 @@ export const Filters = (props: FiltersProps) => { } return ( -
    -
    +
    +
    {filters.map(renderFilter)}
    {renderActions()} @@ -451,22 +451,22 @@ export type SuggestionProps = Omit, 'children'> & { // return ( //
    -//
      +//
        // {typeof children === 'function' // ? children({ close: onClose }) // : renderDropdownItems(children as React.ReactElement) // } //
      -// {withArrow &&
      } +// {withArrow &&
      } //
      // ); // }; @@ -560,20 +560,20 @@ export const SearchInput = (props: SearchInputProps) => { // biome-ignore lint/a11y/useSemanticElements: role="button" tabIndex={0} - className={cx('bkl-search-input', className, { 'bkl-search-input--active': isFocused })} + className={cx('bk-search-input', className, { 'bk-search-input--active': isFocused })} onClick={onWrapperClick} onKeyDown={onWrapperKeyDown} > - + {field && - + {field.label}{operator}{subOperator} {key ? `${key} =` : ''} } { const renderMultiSelectAlternatives = () => ( <> {Object.entries(alternatives || {}).map(([alternativesName, { label }], index) => ( { onSelectionChange(alternativesName, event.target.checked); }} /> ))} {!arrayValidation.isValid && arrayValidation.message && ( - + {arrayValidation.message} )} -
      +
      +
      + + - table.gotoPage(Number.parseInt(event.target.value) - 1) - } + onChange={(event) => setPageIndexIndicator(Number.parseInt(event.target.value))} + onBlur={(event) => { + if(pageIndexIndicator > 0 && pageIndexIndicator <= table.pageCount){ + table.gotoPage(pageIndexIndicator - 1); + } else { + table.gotoPage(table.state.pageIndex); + setPageIndexIndicator(table.state.pageIndex + 1); + } + }} /> of {table.pageCount} + +
      +
      - table.gotoPage(table.pageCount - 1)} - /> +
      ); diff --git a/src/components/tables/DataTable/table/DataTable.tsx b/src/components/tables/DataTable/table/DataTable.tsx index 7238534f..76e43c73 100644 --- a/src/components/tables/DataTable/table/DataTable.tsx +++ b/src/components/tables/DataTable/table/DataTable.tsx @@ -47,7 +47,6 @@ export const DataTable = (props: DataTableProps) => { return (
    {placeholder}
    +
    { row.toggleRowSelected(); }} @@ -121,7 +122,7 @@ export const DataTable = (props: DataTableProps) => { ); })} {typeof endOfTablePlaceholder !== 'undefined' && -
    {endOfTablePlaceholder}
    {columnGroups} @@ -68,8 +67,8 @@ export const DataTable = (props: DataTableProps) => { return ( + {/* ); diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.scss b/src/components/tables/DataTable/table/DataTablePlaceholder.scss index 32b41459..898c6575 100644 --- a/src/components/tables/DataTable/table/DataTablePlaceholder.scss +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.scss @@ -16,20 +16,19 @@ align-items: stretch; justify-content: space-around; gap: 1rem; - margin: -#{bk.$spacing-5} -#{bk.$spacing-2}; - + margin: calc(-1 * bk.$spacing-5) calc(-1 * bk.$spacing-2); >.skeleton-row { border-bottom: 1px solid bk.$theme-rule-default; - gap: calc(2 * #{bk.$spacing-5}); 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:" "; - background-color: bk.$theme-rule-default; + @include bk.shimmer($baseColor: bk.$theme-rule-default, $highlightColor: bk.$theme-table-text-body-default); height: bk.$spacing-2; border-radius: bk.$border-radius-xl; display: block; diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.tsx b/src/components/tables/DataTable/table/DataTablePlaceholder.tsx index 7d94806b..eae6020e 100644 --- a/src/components/tables/DataTable/table/DataTablePlaceholder.tsx +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.tsx @@ -18,16 +18,15 @@ import './DataTablePlaceholder.scss'; // Loading skeleton (when there's no data to show yet) type DataTablePlaceholderSkeletonProps = { className?: ClassNameArgument }; export const DataTablePlaceholderSkeleton = (props: DataTablePlaceholderSkeletonProps) => { - // TODO: WIP for Skeleton loader (shadow per cell), maybe needs shimmer const { table } = useTable(); return (
    {Array.from({ length: 6 }).map((_, index) => // biome-ignore lint/suspicious/noArrayIndexKey: no other unique identifier available - {table.columns.map((_, index) => + {table.visibleColumns.map((col, index) => // biome-ignore lint/suspicious/noArrayIndexKey: no other unique identifier available - + )} , )} diff --git a/src/components/tables/util/generateData.ts b/src/components/tables/util/generateData.ts index 3145c133..140000df 100644 --- a/src/components/tables/util/generateData.ts +++ b/src/components/tables/util/generateData.ts @@ -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 { seed, randFirstName, randLastName, randEmail, randCompanyName, randBetweenDate, randUuid } from '@ngneat/falso'; +import { seed, randFirstName, randLastName, randEmail, randCompanyName, randBetweenDate, randUuid, randJobDescriptor } from '@ngneat/falso'; export type User = { id: string, @@ -10,6 +10,11 @@ export type User = { 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 } = {}) => { @@ -26,6 +31,11 @@ export const generateData = ({ numItems = 10 } = {}) => { 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(), }); } 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/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..567b3b20 --- /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($baseColor, $highlightColor, $shimmerSize: 20rem) { + background-color: $baseColor; + + background-image: linear-gradient( + 90deg, + $baseColor 0%, + $highlightColor 30%, + $baseColor 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; +} From 1913379bfafa4afa1622029f5eab2a024476260b Mon Sep 17 00:00:00 2001 From: "Imad A. Bakir" Date: Wed, 15 Jan 2025 10:48:04 +0100 Subject: [PATCH 18/32] Data Table: PR Review --- .../DataTable/DataTableStream.stories.tsx | 10 +-- .../tables/DataTable/DataTableStream.tsx | 1 - .../tables/DataTable/table/DataTable.tsx | 2 +- .../DataTable/table/DataTablePlaceholder.scss | 24 +------ .../DataTable/table/DataTablePlaceholder.tsx | 65 +------------------ 5 files changed, 9 insertions(+), 93 deletions(-) diff --git a/src/components/tables/DataTable/DataTableStream.stories.tsx b/src/components/tables/DataTable/DataTableStream.stories.tsx index 9fa8b07f..02a1af6c 100644 --- a/src/components/tables/DataTable/DataTableStream.stories.tsx +++ b/src/components/tables/DataTable/DataTableStream.stories.tsx @@ -10,6 +10,8 @@ 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 { @@ -41,8 +43,8 @@ type UserPageState = { offsetApprovalRequests: number, }; type dataTeableLazyTemplateProps = DataTableStream.TableProviderStreamProps & -{ delay: number, items: Array, endOfStream: boolean }; -const DataTableStreamTemplate = (props : dataTeableLazyTemplateProps) => { +{ delay: number, items: Array, endOfStream: boolean, dataTableProps: DataTableAsyncProps }; +const DataTableStreamTemplate = ({dataTableProps, ...props} : dataTeableLazyTemplateProps) => { const columns = useMemo(() => props.columns, [props.columns]); const items = useMemo(() => props.items, [props.items]); const delayQuery = props.delay ?? null; @@ -121,7 +123,7 @@ const DataTableStreamTemplate = (props : dataTeableLazyTemplateProps) => { } /> } - {...props} + {...dataTableProps} /> @@ -229,7 +231,7 @@ export const WithEndOfTablePlaceholder = { columns: columnDefinitions, items: generateData({ numItems: 15 }), dataTableProps: { - placeholderEndOfTable: 'I have no idea', + placeholderEndOfTable: You have reached the end of the table }, }, render: (args: dataTeableLazyTemplateProps) => , diff --git a/src/components/tables/DataTable/DataTableStream.tsx b/src/components/tables/DataTable/DataTableStream.tsx index 32384577..ac33449c 100644 --- a/src/components/tables/DataTable/DataTableStream.tsx +++ b/src/components/tables/DataTable/DataTableStream.tsx @@ -32,7 +32,6 @@ export { Search, MultiSearch } from './DataTableEager.tsx'; // FIXME: move to a export { DataTablePlaceholderEmpty, DataTablePlaceholderError, - DataTablePlaceholderEndOfTable, PlaceholderEmptyAction, } from './table/DataTablePlaceholder.tsx'; diff --git a/src/components/tables/DataTable/table/DataTable.tsx b/src/components/tables/DataTable/table/DataTable.tsx index 76e43c73..603bda1f 100644 --- a/src/components/tables/DataTable/table/DataTable.tsx +++ b/src/components/tables/DataTable/table/DataTable.tsx @@ -188,7 +188,7 @@ export const DataTableSync = (props: DataTableSyncProps) => DataTableSync.displayName = 'DataTableSync'; -type DataTableAsyncProps = DataTableProps & { +export type DataTableAsyncProps = DataTableProps & { classNameTable?: ClassNameArgument, status: DataTableStatus, placeholderSkeleton?: React.ReactNode, diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.scss b/src/components/tables/DataTable/table/DataTablePlaceholder.scss index 898c6575..60950fb8 100644 --- a/src/components/tables/DataTable/table/DataTablePlaceholder.scss +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.scss @@ -37,26 +37,4 @@ } } } -} -// TODO: Figure this thing out -// .bk-table-row-placeholder { -// @include bk.component-base(bk-table-row-placeholder); - -// background: hsla(37, 91%, 55%, 0.04); -// border: 1px solid #F5A623; -// padding: bk.$sizing-3 ( -// bk.$sizing-4 + bk.$sizing-1 -// ); -// height: 5.2rem; -// display: flex; -// align-items: center; - -// .bk-table-row-placeholder__icon { -// color: #F5A623; -// margin-right: bk.$sizing-4 + bk.$sizing-1; -// } - -// .bk-table-row-placeholder__message { -// color: rgba(12, 12, 34, 0.87); -// } -// } \ No newline at end of file +} \ No newline at end of file diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.tsx b/src/components/tables/DataTable/table/DataTablePlaceholder.tsx index eae6020e..616dc567 100644 --- a/src/components/tables/DataTable/table/DataTablePlaceholder.tsx +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.tsx @@ -61,67 +61,4 @@ export const DataTablePlaceholderError = (props: DataTablePlaceholderErrorProps) className={cx('bk-table-placeholder bk-table-placeholder--error', props.className)} /> ); -}; - - -type DataTableRowPlaceholderProps = ComponentProps<'div'> & { - icon?: IconName | React.ReactNode, - classNameIcon?: ClassNameArgument, - classNameMessage?: ClassNameArgument, - classNameActions?: ClassNameArgument, - placeholderMessage: React.ReactNode, - actions?: React.ReactNode, -}; -export const DataTableRowPlaceholder = (props: DataTableRowPlaceholderProps) => { - const { icon, classNameIcon, classNameMessage, classNameActions, placeholderMessage, actions, ...propsRest } = props; - - const renderStandardIcon = (icon: IconProps['icon']): React.ReactNode => { - return ( - - ); - }; - - const renderIcon = (): React.ReactNode => { - if (typeof icon === 'undefined') { - return renderStandardIcon('warning'); - } - if (typeof icon === 'string') { - if (isIconName(icon)) { - return renderStandardIcon(icon); - } - throw new Error(`Invalid icon ${icon}`); - } - return icon; - }; - - return ( -
    - {renderIcon()} - -

    - {placeholderMessage} -

    - - {actions && -

    - {actions} -

    - } -
    - ); -}; - -type DataTablePlaceholderEndOfTableProps = Omit & { - // Make `placeholderMessage` optional - placeholderMessage?: React.ReactNode, -}; - -export const DataTablePlaceholderEndOfTable = (props: DataTablePlaceholderEndOfTableProps) => { - return ( - - ); -}; +}; \ No newline at end of file From c42702d8aa8f3fb2f9f7dd7840296b1a5c9e7b66 Mon Sep 17 00:00:00 2001 From: "Imad A. Bakir" Date: Wed, 15 Jan 2025 10:54:46 +0100 Subject: [PATCH 19/32] Data Table: Replace --legacy-peer-deps with overrides --- .github/workflows/chromatic.yaml | 2 +- .github/workflows/ci.yaml | 2 +- .github/workflows/deploy_storybook.yaml | 2 +- .github/workflows/publish_npm.yaml | 2 +- .github/workflows/storybook_test.yaml | 2 +- package-lock.json | 14 ++++++++++++++ package.json | 6 +++++- package.json.js | 9 ++++++--- 8 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/chromatic.yaml b/.github/workflows/chromatic.yaml index b64fcf3b..0554cb03 100644 --- a/.github/workflows/chromatic.yaml +++ b/.github/workflows/chromatic.yaml @@ -19,7 +19,7 @@ jobs: with: node-version: 22 - name: Install dependencies - run: npm ci --legacy-peer-deps + run: npm ci - name: Run Chromatic uses: chromaui/action@latest with: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7a36ff2e..ed8ce60d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -26,7 +26,7 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: 'npm' - - run: npm ci --legacy-peer-deps + - run: npm ci #- run: npm run build --if-present - run: npm run verify verify:source - run: npm test diff --git a/.github/workflows/deploy_storybook.yaml b/.github/workflows/deploy_storybook.yaml index f14d9bea..e9466477 100644 --- a/.github/workflows/deploy_storybook.yaml +++ b/.github/workflows/deploy_storybook.yaml @@ -19,5 +19,5 @@ jobs: uses: bitovi/github-actions-storybook-to-github-pages@v1.0.3 with: path: storybook-static - install_command: npm ci --legacy-peer-deps + install_command: npm ci build_command: npm run storybook:build diff --git a/.github/workflows/publish_npm.yaml b/.github/workflows/publish_npm.yaml index d9b5f474..e91beb7d 100644 --- a/.github/workflows/publish_npm.yaml +++ b/.github/workflows/publish_npm.yaml @@ -15,7 +15,7 @@ jobs: with: node-version: '22.x' registry-url: 'https://registry.npmjs.org' - - run: npm ci --legacy-peer-deps + - run: npm ci - run: npm run build --if-present - run: npm test - run: npm publish --provenance --access public diff --git a/.github/workflows/storybook_test.yaml b/.github/workflows/storybook_test.yaml index 12ff2d3c..7687e2a5 100644 --- a/.github/workflows/storybook_test.yaml +++ b/.github/workflows/storybook_test.yaml @@ -24,7 +24,7 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: 'npm' - - run: npm ci --legacy-peer-deps + - run: npm ci #- run: npm run build --if-present - run: npm run test:storybook-ci continue-on-error: true # Not enforced for now diff --git a/package-lock.json b/package-lock.json index dbd349a2..ba0e3e57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -931,6 +931,20 @@ "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, + "node_modules/@chromatic-com/storybook/node_modules/react": { + "version": "18.3.1", + "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" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@chromatic-com/storybook/node_modules/react-confetti": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/react-confetti/-/react-confetti-6.1.0.tgz", diff --git a/package.json b/package.json index ccfb3de2..b3c40f65 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ }, "scripts": { "gen-package": "node package.json.js", - "install-project": "npm run gen-package && npm install --legacy-peer-deps", + "install-project": "npm run gen-package && npm install", "node": "node --import=tsx", "repl": "tsx", "plop": "NODE_OPTIONS=\"--import tsx\" plop", @@ -137,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 58c21b2c..e0da48fb 100644 --- a/package.json.js +++ b/package.json.js @@ -47,9 +47,7 @@ const packageConfig = { scripts: { // Utilities 'gen-package': 'node package.json.js', // Update `package.json` - // Currently we are forced to use --legacy-peer-deps due to react-table version (relies on older version of react) - // TODO: Remove --legacy-peer-deps after updating react-table to v8 - 'install-project': 'npm run gen-package && npm install --legacy-peer-deps', // Project-specific version of `npm install` + 'install-project': 'npm run gen-package && npm install', // Project-specific version of `npm install` // CLI 'node': 'node --import=tsx', @@ -208,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', + }, }, }; From 72b5a938b7110dab2c94236a59b5b7ce104b0832 Mon Sep 17 00:00:00 2001 From: mkrause Date: Wed, 15 Jan 2025 17:33:15 +0100 Subject: [PATCH 20/32] Trim 'Load more' button. --- src/components/tables/DataTable/DataTableStream.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/tables/DataTable/DataTableStream.tsx b/src/components/tables/DataTable/DataTableStream.tsx index ac33449c..7fda73f2 100644 --- a/src/components/tables/DataTable/DataTableStream.tsx +++ b/src/components/tables/DataTable/DataTableStream.tsx @@ -494,7 +494,7 @@ export const DataTableStream = ({ }; const renderLoadMoreResults = () => { - return ; + return ; }; // Use `` by default, unless the table is empty (in which case there are "zero" pages) From bfffc8dbb5781db6056a5f4d42409029eb782085 Mon Sep 17 00:00:00 2001 From: "Imad A. Bakir" Date: Thu, 16 Jan 2025 14:23:27 +0100 Subject: [PATCH 21/32] Data Table: PR Review --- .../tables/DataTable/DataTableEager.scss | 9 +- .../DataTable/DataTableEager.stories.tsx | 20 -- .../tables/DataTable/DataTableEager.tsx | 2 +- .../tables/DataTable/DataTableLazy.scss | 8 +- .../DataTable/DataTableLazy.stories.tsx | 4 +- .../tables/DataTable/DataTableLazy.tsx | 4 +- .../DataTable/DataTableStream.stories.tsx | 6 +- .../tables/DataTable/DataTableStream.tsx | 4 +- .../DataTable/pagination/Pagination.scss | 79 +++--- .../DataTable/pagination/Pagination.tsx | 10 +- .../pagination/PaginationSizeSelector.scss | 7 +- .../pagination/PaginationSizeSelector.tsx | 2 +- .../pagination/PaginationStream.scss | 68 +++-- .../DataTable/pagination/PaginationStream.tsx | 8 +- .../tables/DataTable/table/DataTable.scss | 201 +++++++------- .../DataTable/table/DataTablePlaceholder.scss | 55 ++-- .../tables/MultiSearch/MultiSearch.scss | 245 +++++++++--------- .../tables/MultiSearch/MultiSearch.tsx | 10 +- .../tables/SearchInput/SearchInput.scss | 27 +- src/components/tables/util/hooks.ts | 2 +- 20 files changed, 382 insertions(+), 389 deletions(-) diff --git a/src/components/tables/DataTable/DataTableEager.scss b/src/components/tables/DataTable/DataTableEager.scss index ddb58b7c..604d1103 100644 --- a/src/components/tables/DataTable/DataTableEager.scss +++ b/src/components/tables/DataTable/DataTableEager.scss @@ -5,7 +5,8 @@ @use '../../../styling/defs.scss' as bk; @use './DataTableLazy.scss' as dataTableLazy; - -.bk-data-table-eager--loading { - @include dataTableLazy.data-table-loading; -} +@layer baklava.components { + .bk-data-table-eager--loading { + @include dataTableLazy.data-table-loading; + } +} \ No newline at end of file diff --git a/src/components/tables/DataTable/DataTableEager.stories.tsx b/src/components/tables/DataTable/DataTableEager.stories.tsx index fa5ecc24..31f0ef6c 100644 --- a/src/components/tables/DataTable/DataTableEager.stories.tsx +++ b/src/components/tables/DataTable/DataTableEager.stories.tsx @@ -196,26 +196,6 @@ export const MultiplePagesLarge = { render: (args: dataTeableEagerTemplateProps) => , }; -export const SlowNetwork = { - args: { - columns, - items: generateData({ numItems: 1000 }), - delay: 1500, - isReady: false, - }, - render: (args: dataTeableEagerTemplateProps) => , -}; - -export const InfiniteDelay = { - args: { - columns, - items: generateData({ numItems: 10 }), - delay: Number.POSITIVE_INFINITY, - isReady: false, - }, - render: (args: dataTeableEagerTemplateProps) => , -}; - export const WithFilter = { args: { columns, diff --git a/src/components/tables/DataTable/DataTableEager.tsx b/src/components/tables/DataTable/DataTableEager.tsx index 53b42ad5..5bd42a65 100644 --- a/src/components/tables/DataTable/DataTableEager.tsx +++ b/src/components/tables/DataTable/DataTableEager.tsx @@ -14,7 +14,7 @@ import { DataTableSync } from './table/DataTable.tsx'; import './DataTableEager.scss'; -export * from './DataTableContext'; +export * from './DataTableContext.tsx'; export { Pagination }; export { DataTablePlaceholderEmpty, DataTablePlaceholderError } from './table/DataTablePlaceholder'; diff --git a/src/components/tables/DataTable/DataTableLazy.scss b/src/components/tables/DataTable/DataTableLazy.scss index c7a2df34..10d9098b 100644 --- a/src/components/tables/DataTable/DataTableLazy.scss +++ b/src/components/tables/DataTable/DataTableLazy.scss @@ -13,12 +13,14 @@ top: calc(50% - (80px / 2)); left: calc(50% - (80px / 2)); } - + .bk-data-table__table tbody { opacity: 0.6; } } -.bk-data-table-lazy--loading { - @include data-table-loading; +@layer baklava.components { + .bk-data-table-lazy--loading { + @include data-table-loading; + } } \ No newline at end of file diff --git a/src/components/tables/DataTable/DataTableLazy.stories.tsx b/src/components/tables/DataTable/DataTableLazy.stories.tsx index 0a97a28f..90e41f76 100644 --- a/src/components/tables/DataTable/DataTableLazy.stories.tsx +++ b/src/components/tables/DataTable/DataTableLazy.stories.tsx @@ -64,8 +64,8 @@ const DataTableLazyTemplate = (props: dataTeableLazyTemplateProps) => { title="No users" actions={ - - + + } /> diff --git a/src/components/tables/DataTable/DataTableLazy.tsx b/src/components/tables/DataTable/DataTableLazy.tsx index 455f5695..c03134d2 100644 --- a/src/components/tables/DataTable/DataTableLazy.tsx +++ b/src/components/tables/DataTable/DataTableLazy.tsx @@ -20,7 +20,7 @@ import { Icon } from '../../graphics/Icon/Icon.tsx'; import './DataTableLazy.scss'; -export * from './DataTableContext'; +export * from './DataTableContext.tsx'; export { Pagination }; export { DataTablePlaceholderEmpty, DataTablePlaceholderError } from './table/DataTablePlaceholder'; export { PlaceholderEmptyAction } from '../../graphics/PlaceholderEmpty/PlaceholderEmpty.tsx'; @@ -284,7 +284,7 @@ export const DataTableLazy = ({ className, footer, ...propsRest }: DataTableLazy - diff --git a/src/components/tables/DataTable/DataTableStream.stories.tsx b/src/components/tables/DataTable/DataTableStream.stories.tsx index 02a1af6c..bc444ee5 100644 --- a/src/components/tables/DataTable/DataTableStream.stories.tsx +++ b/src/components/tables/DataTable/DataTableStream.stories.tsx @@ -117,8 +117,8 @@ const DataTableStreamTemplate = ({dataTableProps, ...props} : dataTeableLazyTemp title="No users" actions={ - - + + } /> @@ -231,7 +231,7 @@ export const WithEndOfTablePlaceholder = { columns: columnDefinitions, items: generateData({ numItems: 15 }), dataTableProps: { - placeholderEndOfTable: You have reached the end of the table + placeholderEndOfTable: }, }, render: (args: dataTeableLazyTemplateProps) => , diff --git a/src/components/tables/DataTable/DataTableStream.tsx b/src/components/tables/DataTable/DataTableStream.tsx index 7fda73f2..b1572a1d 100644 --- a/src/components/tables/DataTable/DataTableStream.tsx +++ b/src/components/tables/DataTable/DataTableStream.tsx @@ -494,7 +494,7 @@ export const DataTableStream = ({ }; const renderLoadMoreResults = () => { - return ; + return ; }; // Use `` by default, unless the table is empty (in which case there are "zero" pages) @@ -521,7 +521,7 @@ export const DataTableStream = ({ - diff --git a/src/components/tables/DataTable/pagination/Pagination.scss b/src/components/tables/DataTable/pagination/Pagination.scss index d8488939..0d120141 100644 --- a/src/components/tables/DataTable/pagination/Pagination.scss +++ b/src/components/tables/DataTable/pagination/Pagination.scss @@ -5,53 +5,54 @@ @use '../../../../styling/defs.scss' as 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 { +@layer baklava.components { + .bk-pagination { + @include bk.component-base(bk-pagination); 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 { - &:not(:disabled) { - cursor: pointer; + 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 { + &:not(:disabled) { + cursor: pointer; + } + &:disabled { + opacity: 0.34; + } } - &:disabled { - 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__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; } } - .pagination-main{ - display: flex; - gap: bk.$spacing-1; - align-items: center; - } } - - -@media only screen and (max-width <= 1100px) { - .pagination { +@media only screen and (width <= 1100px) { + .bk-pagination { justify-content: flex-start; flex-wrap: wrap; } -} +} \ No newline at end of file diff --git a/src/components/tables/DataTable/pagination/Pagination.tsx b/src/components/tables/DataTable/pagination/Pagination.tsx index f92b001d..9bbbd069 100644 --- a/src/components/tables/DataTable/pagination/Pagination.tsx +++ b/src/components/tables/DataTable/pagination/Pagination.tsx @@ -38,7 +38,7 @@ export const Pagination = ({ pageSizeOptions }: PaginationProps) => { */ return ( -
    +
    @@ -46,7 +46,7 @@ export const Pagination = ({ pageSizeOptions }: PaginationProps) => { unstyled disabled={!table.canPreviousPage} className="pager__nav" - onClick={() => { + onPress={() => { table.gotoPage(0) setPageIndexIndicator(1); }} @@ -61,7 +61,7 @@ export const Pagination = ({ pageSizeOptions }: PaginationProps) => { unstyled disabled={!table.canPreviousPage} className="pager__nav" - onClick={() => { + onPress={() => { table.previousPage(); setPageIndexIndicator(pageIndexIndicator - 1); }} @@ -92,7 +92,7 @@ export const Pagination = ({ pageSizeOptions }: PaginationProps) => { unstyled disabled={!table.canNextPage} className="pager__nav" - onClick={() => { + onPress={() => { table.nextPage(); setPageIndexIndicator(pageIndexIndicator + 1); }} @@ -107,7 +107,7 @@ export const Pagination = ({ pageSizeOptions }: PaginationProps) => { unstyled disabled={!table.canNextPage} className="pager__nav" - onClick={() => { + onPress={() => { table.gotoPage(table.pageCount - 1) setPageIndexIndicator(table.pageCount); }} diff --git a/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss b/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss index 782cf7d8..638dc36f 100644 --- a/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss +++ b/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss @@ -5,12 +5,14 @@ @use '../../../../styling/defs.scss' as bk; -.page-size-selector { +@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__page-size { user-select: none; flex: none; @@ -35,4 +37,5 @@ } } +} } \ No newline at end of file diff --git a/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx b/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx index fcfd83cd..eb31a623 100644 --- a/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx +++ b/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx @@ -26,7 +26,7 @@ export const PaginationSizeSelector = (props: PaginationSizeSelectorProps) => { const { table } = useTable(); return ( -
    +
    {pageSizeLabel}: )}
    ); -}; \ No newline at end of file +}; diff --git a/src/components/tables/DataTable/pagination/PaginationStream.scss b/src/components/tables/DataTable/pagination/PaginationStream.scss index 3ec8e64b..e97440eb 100644 --- a/src/components/tables/DataTable/pagination/PaginationStream.scss +++ b/src/components/tables/DataTable/pagination/PaginationStream.scss @@ -41,4 +41,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/components/tables/DataTable/table/DataTable.scss b/src/components/tables/DataTable/table/DataTable.scss index c68b7ac4..b18374cb 100644 --- a/src/components/tables/DataTable/table/DataTable.scss +++ b/src/components/tables/DataTable/table/DataTable.scss @@ -151,4 +151,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.scss b/src/components/tables/DataTable/table/DataTablePlaceholder.scss index c10d997a..f6c3b82b 100644 --- a/src/components/tables/DataTable/table/DataTablePlaceholder.scss +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.scss @@ -7,27 +7,29 @@ @layer baklava.components { .bk-table-placeholder { @include bk.component-base(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 { + + > .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:" "; + &::after { + content: ' '; @include bk.shimmer($baseColor: bk.$theme-rule-default, $highlightColor: bk.$theme-table-text-body-default); height: bk.$spacing-2; border-radius: bk.$border-radius-xl; @@ -38,4 +40,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.tsx b/src/components/tables/DataTable/table/DataTablePlaceholder.tsx index 616dc567..81020f26 100644 --- a/src/components/tables/DataTable/table/DataTablePlaceholder.tsx +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.tsx @@ -61,4 +61,4 @@ export const DataTablePlaceholderError = (props: DataTablePlaceholderErrorProps) className={cx('bk-table-placeholder bk-table-placeholder--error', props.className)} /> ); -}; \ No newline at end of file +}; diff --git a/src/components/tables/MultiSearch/MultiSearch.scss b/src/components/tables/MultiSearch/MultiSearch.scss index 164062bd..081a85fd 100644 --- a/src/components/tables/MultiSearch/MultiSearch.scss +++ b/src/components/tables/MultiSearch/MultiSearch.scss @@ -152,4 +152,4 @@ .bk-multi-search__dropdown-error-msg { padding: $sizing-s; } -} \ No newline at end of file +} diff --git a/src/components/tables/SearchInput/SearchInput.scss b/src/components/tables/SearchInput/SearchInput.scss index 6550a620..5bff56ac 100644 --- a/src/components/tables/SearchInput/SearchInput.scss +++ b/src/components/tables/SearchInput/SearchInput.scss @@ -21,4 +21,4 @@ bottom: bk.$spacing-1; } } -} \ No newline at end of file +} From e5722d3a06a0c3752dad03a550639e974ca7bcda Mon Sep 17 00:00:00 2001 From: mkrause Date: Thu, 16 Jan 2025 18:44:37 +0100 Subject: [PATCH 25/32] Resolve some comments. --- .../DataTable/pagination/Pagination.scss | 11 ++-- .../DataTable/pagination/PaginationStream.tsx | 2 +- .../DataTable/plugins/useRowSelectColumn.scss | 10 ++- .../tables/DataTable/table/DataTable.scss | 64 +++++++++---------- src/components/text/Tag/Tag.tsx | 1 - 5 files changed, 45 insertions(+), 43 deletions(-) diff --git a/src/components/tables/DataTable/pagination/Pagination.scss b/src/components/tables/DataTable/pagination/Pagination.scss index 04d41370..4c165e98 100644 --- a/src/components/tables/DataTable/pagination/Pagination.scss +++ b/src/components/tables/DataTable/pagination/Pagination.scss @@ -49,10 +49,11 @@ align-items: center; } } -} -@media only screen and (width <= 1100px) { - .bk-pagination { - justify-content: flex-start; - flex-wrap: wrap; + + @media only screen and (width <= 1100px) { + .bk-pagination { + justify-content: flex-start; + flex-wrap: wrap; + } } } diff --git a/src/components/tables/DataTable/pagination/PaginationStream.tsx b/src/components/tables/DataTable/pagination/PaginationStream.tsx index 05658658..78ddbcfe 100644 --- a/src/components/tables/DataTable/pagination/PaginationStream.tsx +++ b/src/components/tables/DataTable/pagination/PaginationStream.tsx @@ -49,7 +49,7 @@ export const PaginationStreamPager = ({ pageSizeOptions }: PaginationStreamPager onPress={() => { table.previousPage(); }} disabled={!table.canPreviousPage} > - + Previous diff --git a/src/components/tables/DataTable/plugins/useRowSelectColumn.scss b/src/components/tables/DataTable/plugins/useRowSelectColumn.scss index 9f118ff9..5b9d529f 100644 --- a/src/components/tables/DataTable/plugins/useRowSelectColumn.scss +++ b/src/components/tables/DataTable/plugins/useRowSelectColumn.scss @@ -4,7 +4,11 @@ @use '../../../../styling/defs.scss' as bk; -.bk-data-table-row-select { - width: bk.$spacing-11; - max-width: bk.$spacing-11; +@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/table/DataTable.scss b/src/components/tables/DataTable/table/DataTable.scss index b18374cb..8b772fda 100644 --- a/src/components/tables/DataTable/table/DataTable.scss +++ b/src/components/tables/DataTable/table/DataTable.scss @@ -9,12 +9,12 @@ .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; @@ -22,51 +22,50 @@ 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; @@ -75,25 +74,25 @@ } } } - + 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 { + > td { font-size: bk.$font-size-m; } } } } - + tfoot { td { overflow: visible; // Allow overflow due to dropdown menu @@ -106,45 +105,44 @@ 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 { + + > 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 { + + > td:empty { display: none; } } diff --git a/src/components/text/Tag/Tag.tsx b/src/components/text/Tag/Tag.tsx index d05cdd89..43715709 100644 --- a/src/components/text/Tag/Tag.tsx +++ b/src/components/text/Tag/Tag.tsx @@ -23,7 +23,6 @@ export type TagProps = Omit, 'content' | 'children'> & { /** Callback to remove the tag. If set, display a close icon, otherwise it is hidden. */ onRemove?: () => void, }; -type Test = ComponentProps<'div'>['content']; /** * A tag component. */ From fa40ec1e86d0080885fab809776912b7b5523aca Mon Sep 17 00:00:00 2001 From: mkrause Date: Thu, 16 Jan 2025 19:22:02 +0100 Subject: [PATCH 26/32] Refactor Pagination styling to Sass modules. --- .../actions/Button/Button.module.scss | 10 ++-- src/components/actions/Button/Button.tsx | 3 +- ...Pagination.scss => Pagination.module.scss} | 14 ++--- .../DataTable/pagination/Pagination.tsx | 51 +++++++------------ ...ream.scss => PaginationStream.module.scss} | 17 ++++--- .../DataTable/pagination/PaginationStream.tsx | 45 ++++++---------- 6 files changed, 61 insertions(+), 79 deletions(-) rename src/components/tables/DataTable/pagination/{Pagination.scss => Pagination.module.scss} (88%) rename src/components/tables/DataTable/pagination/{PaginationStream.scss => PaginationStream.module.scss} (85%) diff --git a/src/components/actions/Button/Button.module.scss b/src/components/actions/Button/Button.module.scss index e27b1637..457e8a9c 100644 --- a/src/components/actions/Button/Button.module.scss +++ b/src/components/actions/Button/Button.module.scss @@ -15,16 +15,18 @@ 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/tables/DataTable/pagination/Pagination.scss b/src/components/tables/DataTable/pagination/Pagination.module.scss similarity index 88% rename from src/components/tables/DataTable/pagination/Pagination.scss rename to src/components/tables/DataTable/pagination/Pagination.module.scss index 4c165e98..fc4b1a20 100644 --- a/src/components/tables/DataTable/pagination/Pagination.scss +++ b/src/components/tables/DataTable/pagination/Pagination.module.scss @@ -21,17 +21,19 @@ gap: bk.$spacing-1; padding-left: bk.$spacing-6; border-left: bk.$size-1 solid bk.$theme-pagination-border-default; - - .pager__nav { - &:not(:disabled) { + + .pager__nav { + display: flex; + + &:not(:global(.nonactive)) { cursor: pointer; } - &:disabled { + &:global(.nonactive) { opacity: 0.34; } } } - .pagination__page-input{ + .pagination__page-input { text-align: center; border: bk.$size-1 solid bk.$theme-pagination-border-default; border-radius: bk.$size-2; @@ -43,7 +45,7 @@ margin: 0; } } - .pagination-main{ + .pagination-main { display: flex; gap: bk.$spacing-1; align-items: center; diff --git a/src/components/tables/DataTable/pagination/Pagination.tsx b/src/components/tables/DataTable/pagination/Pagination.tsx index 606ca1aa..434f3330 100644 --- a/src/components/tables/DataTable/pagination/Pagination.tsx +++ b/src/components/tables/DataTable/pagination/Pagination.tsx @@ -15,7 +15,7 @@ import { } from './PaginationSizeSelector.tsx'; import { useTable } from '../DataTableContext.tsx'; -import './Pagination.scss'; +import cl from './Pagination.module.scss'; type PaginationProps = { @@ -39,47 +39,41 @@ export const Pagination = ({ pageSizeOptions }: PaginationProps) => { */ return ( -
    +
    -
    +
    -
    +
    setPageIndexIndicator(Number.parseInt(event.target.value))} - onBlur={(event) => { + onBlur={() => { if(pageIndexIndicator > 0 && pageIndexIndicator <= table.pageCount){ table.gotoPage(pageIndexIndicator - 1); } else { @@ -88,36 +82,29 @@ export const Pagination = ({ pageSizeOptions }: PaginationProps) => { } }} /> - of {table.pageCount} + of {Math.max(table.pageCount, 1)}
    diff --git a/src/components/tables/DataTable/pagination/PaginationStream.scss b/src/components/tables/DataTable/pagination/PaginationStream.module.scss similarity index 85% rename from src/components/tables/DataTable/pagination/PaginationStream.scss rename to src/components/tables/DataTable/pagination/PaginationStream.module.scss index e97440eb..d5317faa 100644 --- a/src/components/tables/DataTable/pagination/PaginationStream.scss +++ b/src/components/tables/DataTable/pagination/PaginationStream.module.scss @@ -4,35 +4,38 @@ @use '../../../../styling/defs.scss' as bk; -@use './Pagination.scss'; +@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; - - &.disabled { + + display: flex; + + &:global(.nonactive) { opacity: 0.4; } - + + &.pager__nav--first { --keep: ; } &.pager__nav--prev, &.pager__nav--next { display: flex; diff --git a/src/components/tables/DataTable/pagination/PaginationStream.tsx b/src/components/tables/DataTable/pagination/PaginationStream.tsx index 78ddbcfe..61d531b3 100644 --- a/src/components/tables/DataTable/pagination/PaginationStream.tsx +++ b/src/components/tables/DataTable/pagination/PaginationStream.tsx @@ -11,22 +11,9 @@ import { Button } from '../../../actions/Button/Button.tsx'; import { type PageSizeOption, PaginationSizeSelector } from './PaginationSizeSelector.tsx'; import { useTable } from '../DataTableContext.tsx'; -import './PaginationStream.scss'; +import cl from './PaginationStream.module.scss'; -type IconDoubleChevronLeftProps = React.ComponentPropsWithoutRef<'span'> & { - iconProps?: Partial>, -}; -const IconDoubleChevronLeft = ({ iconProps = {}, ...props }: IconDoubleChevronLeftProps) => { - return ( - - - - ); -}; - type PaginationStreamPagerProps = { pageSizeOptions?: PageSizeOption, }; @@ -34,33 +21,33 @@ export const PaginationStreamPager = ({ pageSizeOptions }: PaginationStreamPager const { table } = useTable(); return ( -
    - - -
    ); @@ -73,9 +60,9 @@ type PaginationStreamProps = { }; export const PaginationStream = ({ renderLoadMoreResults, pageSizeOptions, pageSizeLabel }: PaginationStreamProps) => { return ( -
    +
    {renderLoadMoreResults && ( -
    {renderLoadMoreResults?.()}
    +
    {renderLoadMoreResults?.()}
    )} From 68aed0e659df533431e8701863f45c92a797543e Mon Sep 17 00:00:00 2001 From: mkrause Date: Thu, 16 Jan 2025 19:29:59 +0100 Subject: [PATCH 27/32] Comment out some stories that are not ready. --- .../DataTable/DataTableEager.stories.tsx | 31 ++++++++++--------- ...mn.scss => useRowSelectColumn.module.scss} | 0 .../DataTable/plugins/useRowSelectColumn.tsx | 13 +++----- ...archInput.scss => SearchInput.module.scss} | 5 ++- .../tables/SearchInput/SearchInput.tsx | 11 +++---- 5 files changed, 26 insertions(+), 34 deletions(-) rename src/components/tables/DataTable/plugins/{useRowSelectColumn.scss => useRowSelectColumn.module.scss} (100%) rename src/components/tables/SearchInput/{SearchInput.scss => SearchInput.module.scss} (95%) diff --git a/src/components/tables/DataTable/DataTableEager.stories.tsx b/src/components/tables/DataTable/DataTableEager.stories.tsx index 7528402d..2f76deb9 100644 --- a/src/components/tables/DataTable/DataTableEager.stories.tsx +++ b/src/components/tables/DataTable/DataTableEager.stories.tsx @@ -114,6 +114,7 @@ const DataTableEagerTemplate = (props: dataTeableEagerTemplateProps) => { columns={memoizedColumns} items={memoizedItems} getRowId={(item: User) => item.id} + plugins={[DataTablePlugins.useRowSelectColumn]} > @@ -195,13 +196,13 @@ export const MultiplePagesLarge = { render: (args: dataTeableEagerTemplateProps) => , }; -export const WithFilter = { - args: { - columns, - items: generateData({ numItems: 45 }), - }, - render: (args: dataTeableEagerTemplateProps) => , -}; +// export const WithFilter = { +// args: { +// columns, +// items: generateData({ numItems: 45 }), +// }, +// render: (args: dataTeableEagerTemplateProps) => , +// }; const moreColumns = [ ...columns, @@ -242,11 +243,11 @@ const moreColumns = [ disableGlobalFilter: true, }, ]; -export const WithScroll = { - args: { - columns: moreColumns, - items: generateData({ numItems: 45 }), - className: 'teas' - }, - render: (args: dataTeableEagerTemplateProps) => , -}; +// 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/plugins/useRowSelectColumn.scss b/src/components/tables/DataTable/plugins/useRowSelectColumn.module.scss similarity index 100% rename from src/components/tables/DataTable/plugins/useRowSelectColumn.scss rename to src/components/tables/DataTable/plugins/useRowSelectColumn.module.scss diff --git a/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx b/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx index 9d61d648..ce3cfe43 100644 --- a/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx +++ b/src/components/tables/DataTable/plugins/useRowSelectColumn.tsx @@ -2,12 +2,11 @@ |* 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'; import { Checkbox } from '../../../forms/controls/Checkbox/Checkbox.tsx'; -import './useRowSelectColumn.scss'; +import cl from './useRowSelectColumn.module.scss'; // `react-table` plugin for row selection column. Note: depends on `react-table`'s `useRowSelect` plugin. @@ -16,21 +15,17 @@ export const useRowSelectColumn = (hooks: ReactTable.Hooks) hooks.visibleColumns.push(columns => [ { id: 'selection', - className: 'bk-data-table-row-select', + className: cl['bk-data-table-row-select'], Header: ({ getToggleAllPageRowsSelectedProps }) => { const { checked, onChange } = getToggleAllPageRowsSelectedProps(); return ( -
    - -
    + ); }, Cell: ({ row }: ReactTable.CellProps) => { const { checked, onChange } = row.getToggleRowSelectedProps(); return ( -
    - -
    + ); }, }, diff --git a/src/components/tables/SearchInput/SearchInput.scss b/src/components/tables/SearchInput/SearchInput.module.scss similarity index 95% rename from src/components/tables/SearchInput/SearchInput.scss rename to src/components/tables/SearchInput/SearchInput.module.scss index 5bff56ac..a148f3cf 100644 --- a/src/components/tables/SearchInput/SearchInput.scss +++ b/src/components/tables/SearchInput/SearchInput.module.scss @@ -8,15 +8,14 @@ .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; - font-size: 14px; left: 0; bottom: bk.$spacing-1; } diff --git a/src/components/tables/SearchInput/SearchInput.tsx b/src/components/tables/SearchInput/SearchInput.tsx index 65b7a853..17a84d90 100644 --- a/src/components/tables/SearchInput/SearchInput.tsx +++ b/src/components/tables/SearchInput/SearchInput.tsx @@ -9,23 +9,20 @@ import { classNames as cx } from '../../../util/componentUtil.ts'; import { Icon } from '../../graphics/Icon/Icon.tsx'; import { Input } from '../../forms/controls/Input/Input.tsx'; -import './SearchInput.scss'; +import cl from './SearchInput.module.scss'; export type SearchInputProps = React.ComponentPropsWithoutRef; export const SearchInput = (props: SearchInputProps) => { return ( -
    - +
    +
    ); }; - SearchInput.displayName = 'SearchInput'; From d3c3b9c7660051b4407089330b19b36783b83f78 Mon Sep 17 00:00:00 2001 From: mkrause Date: Thu, 16 Jan 2025 19:58:36 +0100 Subject: [PATCH 28/32] Convert some .scss files to .module.scss. --- ...bleEager.scss => DataTableEager.module.scss} | 10 +++++++--- .../tables/DataTable/DataTableEager.tsx | 6 +++--- ...TableLazy.scss => DataTableLazy.module.scss} | 12 ++++++++---- .../tables/DataTable/DataTableLazy.tsx | 10 +++++----- .../DataTable/DataTableStream.module.scss | 17 +++++++++++++++++ .../tables/DataTable/DataTableStream.scss | 11 ----------- .../tables/DataTable/DataTableStream.tsx | 10 +++++----- .../DataTable/pagination/Pagination.module.scss | 1 + ...er.scss => DataTablePlaceholder.module.scss} | 7 ++++--- .../DataTable/table/DataTablePlaceholder.tsx | 15 +++++++-------- 10 files changed, 57 insertions(+), 42 deletions(-) rename src/components/tables/DataTable/{DataTableEager.scss => DataTableEager.module.scss} (57%) rename src/components/tables/DataTable/{DataTableLazy.scss => DataTableLazy.module.scss} (76%) create mode 100644 src/components/tables/DataTable/DataTableStream.module.scss delete mode 100644 src/components/tables/DataTable/DataTableStream.scss rename src/components/tables/DataTable/table/{DataTablePlaceholder.scss => DataTablePlaceholder.module.scss} (90%) diff --git a/src/components/tables/DataTable/DataTableEager.scss b/src/components/tables/DataTable/DataTableEager.module.scss similarity index 57% rename from src/components/tables/DataTable/DataTableEager.scss rename to src/components/tables/DataTable/DataTableEager.module.scss index 9f97a1fb..04a4703f 100644 --- a/src/components/tables/DataTable/DataTableEager.scss +++ b/src/components/tables/DataTable/DataTableEager.module.scss @@ -3,10 +3,14 @@ |* 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.scss' as dataTableLazy; +@use './DataTableLazy.module.scss' as dataTableLazy; @layer baklava.components { - .bk-data-table-eager--loading { - @include dataTableLazy.data-table-loading; + .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.tsx b/src/components/tables/DataTable/DataTableEager.tsx index 3fa1210d..5aa62fbc 100644 --- a/src/components/tables/DataTable/DataTableEager.tsx +++ b/src/components/tables/DataTable/DataTableEager.tsx @@ -12,7 +12,7 @@ import { SearchInput } from '../SearchInput/SearchInput.tsx'; import { MultiSearch as MultiSearchInput } from '../MultiSearch/MultiSearch.tsx'; import { DataTableSync } from './table/DataTable.tsx'; -import './DataTableEager.scss'; +import cl from './DataTableEager.module.scss'; export * from './DataTableContext.tsx'; export { Pagination }; @@ -163,8 +163,8 @@ export const DataTableEager = ({ children, className, footer, ...propsRest }: Da footer={footerWithFallback} {...propsRest} className={cx( - 'bk-data-table-eager', - { 'bk-data-table-eager--loading': !status.ready }, + cl['bk-data-table-eager'], + { [cl['bk-data-table-eager--loading']]: !status.ready }, className, )} table={table} diff --git a/src/components/tables/DataTable/DataTableLazy.scss b/src/components/tables/DataTable/DataTableLazy.module.scss similarity index 76% rename from src/components/tables/DataTable/DataTableLazy.scss rename to src/components/tables/DataTable/DataTableLazy.module.scss index b75f379d..0c3cdf4d 100644 --- a/src/components/tables/DataTable/DataTableLazy.scss +++ b/src/components/tables/DataTable/DataTableLazy.module.scss @@ -7,20 +7,24 @@ @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--loading { - @include data-table-loading; + .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.tsx b/src/components/tables/DataTable/DataTableLazy.tsx index cc35e056..2a05e951 100644 --- a/src/components/tables/DataTable/DataTableLazy.tsx +++ b/src/components/tables/DataTable/DataTableLazy.tsx @@ -16,7 +16,7 @@ import { Pagination } from './pagination/Pagination'; import { DataTablePlaceholderError } from './table/DataTablePlaceholder'; import { DataTableAsync } from './table/DataTable'; -import './DataTableLazy.scss'; +import cl from './DataTableLazy.module.scss'; export * from './DataTableContext.tsx'; @@ -272,8 +272,8 @@ export const DataTableLazy = ({ className, footer, ...propsRest }: DataTableLazy return ( - @@ -292,7 +292,7 @@ export const DataTableLazy = ({ className, footer, ...propsRest }: DataTableLazy } {...propsRest} > - {showLoadingIndicator && } + {showLoadingIndicator && } ); }; 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.scss b/src/components/tables/DataTable/DataTableStream.scss deleted file mode 100644 index 3b931d30..00000000 --- a/src/components/tables/DataTable/DataTableStream.scss +++ /dev/null @@ -1,11 +0,0 @@ -/* 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 bkl; -@use './DataTableLazy.scss' as dataTableLazy; - - -.bk-data-table-stream--loading { - @include dataTableLazy.data-table-loading; -} diff --git a/src/components/tables/DataTable/DataTableStream.tsx b/src/components/tables/DataTable/DataTableStream.tsx index 1e6626e2..5c23594c 100644 --- a/src/components/tables/DataTable/DataTableStream.tsx +++ b/src/components/tables/DataTable/DataTableStream.tsx @@ -21,7 +21,7 @@ import type { FilterQuery } from '../MultiSearch/filterQuery.ts'; import { useCustomFilters } from './plugins/useCustomFilters.tsx'; // Styles -import './DataTableStream.scss'; +import cl from './DataTableStream.module.scss'; export * from './DataTableContext.tsx'; @@ -508,8 +508,8 @@ export const DataTableStream = ({ return ( - @@ -533,7 +533,7 @@ export const DataTableStream = ({ } {...propsRest} > - {showLoadingIndicator && } + {showLoadingIndicator && } ); }; diff --git a/src/components/tables/DataTable/pagination/Pagination.module.scss b/src/components/tables/DataTable/pagination/Pagination.module.scss index fc4b1a20..a4e70999 100644 --- a/src/components/tables/DataTable/pagination/Pagination.module.scss +++ b/src/components/tables/DataTable/pagination/Pagination.module.scss @@ -8,6 +8,7 @@ @layer baklava.components { .bk-pagination { @include bk.component-base(bk-pagination); + display: flex; align-items: center; justify-content: flex-end; diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.scss b/src/components/tables/DataTable/table/DataTablePlaceholder.module.scss similarity index 90% rename from src/components/tables/DataTable/table/DataTablePlaceholder.scss rename to src/components/tables/DataTable/table/DataTablePlaceholder.module.scss index f6c3b82b..bff044f3 100644 --- a/src/components/tables/DataTable/table/DataTablePlaceholder.scss +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.module.scss @@ -4,10 +4,8 @@ @use '../../../../styling/defs.scss' as bk; -@layer baklava.components { +@layer baklava.components { .bk-table-placeholder { - @include bk.component-base(bk-table-placeholder); - &:not(.bk-table-placeholder--skeleton) { padding: bk.$spacing-14 bk.$spacing-2; } @@ -39,5 +37,8 @@ } } } + + &.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 index 81020f26..8854e7ca 100644 --- a/src/components/tables/DataTable/table/DataTablePlaceholder.tsx +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.tsx @@ -3,8 +3,7 @@ |* 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, type ComponentProps } from '../../../../util/componentUtil.ts'; -import { type IconName, isIconName, Icon, type IconProps } from '../../../graphics/Icon/Icon.tsx'; +import { classNames as cx, type ClassNameArgument } from '../../../../util/componentUtil.ts'; import { PlaceholderEmpty, type PlaceholderEmptyProps } from '../../../graphics/PlaceholderEmpty/PlaceholderEmpty.tsx'; import { useTable } from '../DataTableContext.tsx'; @@ -12,7 +11,7 @@ export { PlaceholderEmptyAction, } from '../../../graphics/PlaceholderEmpty/PlaceholderEmpty.tsx'; -import './DataTablePlaceholder.scss'; +import cl from './DataTablePlaceholder.module.scss'; // Loading skeleton (when there's no data to show yet) @@ -20,13 +19,13 @@ 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 - + )} , )} @@ -44,7 +43,7 @@ export const DataTablePlaceholderEmpty = (props: DataTablePlaceholderEmptyProps) ); }; @@ -58,7 +57,7 @@ export const DataTablePlaceholderError = (props: DataTablePlaceholderErrorProps) ); }; From 9d9ebaf9549aeafb990b9c600cd315bb7e4ceaed Mon Sep 17 00:00:00 2001 From: mkrause Date: Thu, 16 Jan 2025 20:04:07 +0100 Subject: [PATCH 29/32] Convert PaginationSizeSelector.scss to .module.scss. --- ...r.scss => PaginationSizeSelector.module.scss} | 13 +------------ .../pagination/PaginationSizeSelector.tsx | 16 ++++++++-------- 2 files changed, 9 insertions(+), 20 deletions(-) rename src/components/tables/DataTable/pagination/{PaginationSizeSelector.scss => PaginationSizeSelector.module.scss} (79%) diff --git a/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss b/src/components/tables/DataTable/pagination/PaginationSizeSelector.module.scss similarity index 79% rename from src/components/tables/DataTable/pagination/PaginationSizeSelector.scss rename to src/components/tables/DataTable/pagination/PaginationSizeSelector.module.scss index 5d057d1f..141cad0e 100644 --- a/src/components/tables/DataTable/pagination/PaginationSizeSelector.scss +++ b/src/components/tables/DataTable/pagination/PaginationSizeSelector.module.scss @@ -8,22 +8,12 @@ @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__page-size { - user-select: none; - flex: none; - display: flex; - align-items: center; - - .icon-caret { - width: 0.8em; - } - } - .page-size-selector__button { color: bk.$theme-pagination-text-default; padding: 0; @@ -38,4 +28,3 @@ } } } - diff --git a/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx b/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx index 762d6389..45c44639 100644 --- a/src/components/tables/DataTable/pagination/PaginationSizeSelector.tsx +++ b/src/components/tables/DataTable/pagination/PaginationSizeSelector.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 type React from 'react'; +import { classNames as cx } from '../../../../util/componentUtil.ts'; import { Icon } from '../../../graphics/Icon/Icon.tsx'; import { Button } from '../../../actions/Button/Button.tsx'; @@ -10,15 +10,15 @@ import { DropdownMenuProvider } from '../../../overlays/DropdownMenu/DropdownMen import { useTable } from '../DataTableContext.tsx'; -import './PaginationSizeSelector.scss'; +import cl from './PaginationSizeSelector.module.scss'; export type PageSizeOption = number; export const defaultPageSizeOptions: Array = [10, 25, 50, 100]; type PaginationSizeSelectorProps = { - pageSizeOptions?: Array | undefined, - pageSizeLabel?: string | undefined, + pageSizeOptions?: undefined | Array, + pageSizeLabel?: undefined | string, }; export const PaginationSizeSelector = (props: PaginationSizeSelectorProps) => { const { pageSizeOptions = defaultPageSizeOptions, pageSizeLabel = 'Rows per page' } = props; @@ -26,11 +26,11 @@ export const PaginationSizeSelector = (props: PaginationSizeSelectorProps) => { const { table } = useTable(); return ( -
    +
    {pageSizeLabel}: ( { )} From 4297bbc002ccf6f3e4d437c0a42fcc15edadaa86 Mon Sep 17 00:00:00 2001 From: "Imad A. Bakir" Date: Fri, 17 Jan 2025 03:26:43 +0100 Subject: [PATCH 30/32] Data Table: Convert to DataTable.module.scss + move hooks higher. --- .../DataTable/DataTableEager.stories.tsx | 12 +++++- .../tables/DataTable/DataTableLazy.tsx | 2 +- .../{DataTable.scss => DataTable.module.scss} | 0 .../tables/DataTable/table/DataTable.tsx | 39 ++++++++++--------- src/components/tables/util/hooks.ts | 21 ---------- src/util/reactUtil.ts | 15 +++++++ 6 files changed, 47 insertions(+), 42 deletions(-) rename src/components/tables/DataTable/table/{DataTable.scss => DataTable.module.scss} (100%) delete mode 100644 src/components/tables/util/hooks.ts diff --git a/src/components/tables/DataTable/DataTableEager.stories.tsx b/src/components/tables/DataTable/DataTableEager.stories.tsx index 2f76deb9..9436c8da 100644 --- a/src/components/tables/DataTable/DataTableEager.stories.tsx +++ b/src/components/tables/DataTable/DataTableEager.stories.tsx @@ -5,9 +5,9 @@ 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 { useEffectAsync } from '../util/hooks.ts'; import { sortDateTime } from '../util/sorting_util.ts'; import * as Filtering from './filtering/Filtering.ts'; import type { Fields, FilterQuery } from '../MultiSearch/filterQuery.ts'; @@ -196,6 +196,16 @@ export const MultiplePagesLarge = { render: (args: dataTeableEagerTemplateProps) => , }; +export const AsyncInitialization = { + args: { + columns, + items: generateData({ numItems: 1000 }), + delay: 1500, + isReady: false, + }, + render: (args: dataTeableEagerTemplateProps) => , +}; + // export const WithFilter = { // args: { // columns, diff --git a/src/components/tables/DataTable/DataTableLazy.tsx b/src/components/tables/DataTable/DataTableLazy.tsx index 2a05e951..38c7beb0 100644 --- a/src/components/tables/DataTable/DataTableLazy.tsx +++ b/src/components/tables/DataTable/DataTableLazy.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { classNames as cx } from '../../../util/componentUtil.ts'; -import { useEffectAsync } from '../util/hooks.ts'; +import { useEffectAsync } from '../../../util/reactUtil.ts'; import { Spinner } from '../../graphics/Spinner/Spinner.tsx'; import { PlaceholderEmptyAction } from '../../graphics/PlaceholderEmpty/PlaceholderEmpty.tsx'; diff --git a/src/components/tables/DataTable/table/DataTable.scss b/src/components/tables/DataTable/table/DataTable.module.scss similarity index 100% rename from src/components/tables/DataTable/table/DataTable.scss rename to src/components/tables/DataTable/table/DataTable.module.scss diff --git a/src/components/tables/DataTable/table/DataTable.tsx b/src/components/tables/DataTable/table/DataTable.tsx index 603bda1f..03582d1a 100644 --- a/src/components/tables/DataTable/table/DataTable.tsx +++ b/src/components/tables/DataTable/table/DataTable.tsx @@ -16,7 +16,7 @@ import { } from './DataTablePlaceholder.tsx'; import type { DataTableStatus } from '../DataTableContext.tsx'; -import './DataTable.scss'; +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 @@ -39,7 +39,7 @@ export const DataTable = (props: DataTableProps) => { children, ...propsRest } = props; - + // Currently we only support one header group const headerGroup: undefined | ReactTable.HeaderGroup = table.headerGroups[0]; if (!headerGroup) { return null; } @@ -47,7 +47,7 @@ export const DataTable = (props: DataTableProps) => { return (
    {/* Wrapper element needed to serve as flex container */} @@ -103,7 +102,7 @@ export const DataTable = (props: DataTableProps) => { table.prepareRow(row); const { key: rowKey, ...rowProps } = row.getRowProps(); return ( -
    (props: DataTableProps) => { {row.cells.map(cell => { const { key: cellKey, ...cellProps } = cell.getCellProps(); return ( - + {cell.render('Cell')}
    {columnGroups} @@ -71,16 +71,17 @@ export const DataTable = (props: DataTableProps) => { key={headerKey} title={undefined} // Unset the default `title` from `getHeaderProps()` > -
    {/* Wrapper element needed to serve as flex container */} - +
    {/* Wrapper element needed to serve as flex container */} + {column.render('Header')} {column.canSort && } @@ -92,7 +93,7 @@ export const DataTable = (props: DataTableProps) => {
    {typeof placeholder !== 'undefined' && - + @@ -102,7 +103,7 @@ export const DataTable = (props: DataTableProps) => { table.prepareRow(row); const { key: rowKey, ...rowProps } = row.getRowProps(); return ( - + {/* + @@ -153,8 +154,8 @@ export const DataTableSync = (props: DataTableSyncProps) => const { className, classNameTable, - placeholderEmpty = , - placeholderSkeleton = , + placeholderEmpty = , + placeholderSkeleton = , status, ...propsRest } = props; @@ -175,7 +176,7 @@ export const DataTableSync = (props: DataTableSyncProps) => return (
    (props: DataTableAsyncProps) className, classNameTable, status, - placeholderSkeleton = , - placeholderEmpty = , - placeholderError = , + placeholderSkeleton = , + placeholderEmpty = , + placeholderError = , placeholderEndOfTable, children, ...propsRest @@ -233,7 +234,7 @@ export const DataTableAsync = (props: DataTableAsyncProps) return (
    {children} diff --git a/src/components/tables/util/hooks.ts b/src/components/tables/util/hooks.ts deleted file mode 100644 index 087522f7..00000000 --- a/src/components/tables/util/hooks.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* 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 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); -}; 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); +}; From 5390f98e0e8c5e589d8582e1256d4b3a94ca9c01 Mon Sep 17 00:00:00 2001 From: mkrause Date: Fri, 17 Jan 2025 09:49:15 +0100 Subject: [PATCH 31/32] Tweak shimmer effect according to design input. --- .../table/DataTablePlaceholder.module.scss | 3 ++- src/styling/global/shimmer.scss | 16 ++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.module.scss b/src/components/tables/DataTable/table/DataTablePlaceholder.module.scss index bff044f3..719490f6 100644 --- a/src/components/tables/DataTable/table/DataTablePlaceholder.module.scss +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.module.scss @@ -28,7 +28,8 @@ padding-left: bk.$spacing-2; &::after { content: ' '; - @include bk.shimmer($baseColor: bk.$theme-rule-default, $highlightColor: bk.$theme-table-text-body-default); + $shimmer-highlight-color: light-dark(#CBCEDB, #767699); // FIXME: design token + @include bk.shimmer($base-color: bk.$theme-rule-default, $highlight-color: $shimmer-highlight-color); height: bk.$spacing-2; border-radius: bk.$border-radius-xl; display: block; diff --git a/src/styling/global/shimmer.scss b/src/styling/global/shimmer.scss index 567b3b20..8eb68f00 100644 --- a/src/styling/global/shimmer.scss +++ b/src/styling/global/shimmer.scss @@ -2,17 +2,17 @@ |* 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($baseColor, $highlightColor, $shimmerSize: 20rem) { - background-color: $baseColor; - +@mixin shimmer($base-color, $highlight-color) { + background-color: $base-color; + background-image: linear-gradient( 90deg, - $baseColor 0%, - $highlightColor 30%, - $baseColor 60% + $base-color 0%, + $highlight-color 30%, + $base-color 60% ); background-size: 200% 100%; - + @keyframes shimmer { from { background-position: 200% 0; @@ -21,7 +21,7 @@ background-position: -200% 0; } } - + animation: shimmer 3s ease-in-out; animation-iteration-count: infinite; } From 62c04f42cdda3609e19472cd5eda1b5c47a5c967 Mon Sep 17 00:00:00 2001 From: mkrause Date: Fri, 17 Jan 2025 09:54:36 +0100 Subject: [PATCH 32/32] Tweak shimmer effect according to design input. --- .../tables/DataTable/table/DataTablePlaceholder.module.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/tables/DataTable/table/DataTablePlaceholder.module.scss b/src/components/tables/DataTable/table/DataTablePlaceholder.module.scss index 719490f6..72f567c9 100644 --- a/src/components/tables/DataTable/table/DataTablePlaceholder.module.scss +++ b/src/components/tables/DataTable/table/DataTablePlaceholder.module.scss @@ -28,8 +28,9 @@ 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: bk.$theme-rule-default, $highlight-color: $shimmer-highlight-color); + @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;
    {placeholder}
    (props: DataTableProps) => { ); })} {typeof endOfTablePlaceholder !== 'undefined' && -
    {endOfTablePlaceholder}