diff --git a/__mocks__/zustand.ts b/__mocks__/zustand.ts deleted file mode 100644 index 5a9b3ac2..00000000 --- a/__mocks__/zustand.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { act } from '@testing-library/react'; -import { afterEach, vi } from 'vitest'; -import * as zustand from 'zustand'; -import type { StateCreator, StoreApi, UseBoundStore } from 'zustand'; - -const { create: zCreate, createStore: zCreateStore } = await vi.importActual('zustand'); - -// a variable to hold reset functions for all stores declared in the app -const STORE_RESET_FUNCTIONS = new Set<() => void>(); - -function createUncurried(stateCreator: StateCreator): UseBoundStore> { - const store = zCreate(stateCreator); - const initialState = store.getInitialState(); - STORE_RESET_FUNCTIONS.add(() => { - store.setState(initialState, true); - }); - return store; -} - -function createStoreUncurried(stateCreator: StateCreator): StoreApi { - const store = zCreateStore(stateCreator); - const initialState = store.getInitialState(); - STORE_RESET_FUNCTIONS.add(() => { - store.setState(initialState, true); - }); - return store; -} - -export function create(stateCreator: StateCreator): UseBoundStore> { - if (typeof stateCreator === 'function') { - return createUncurried(stateCreator); - } - return createUncurried as unknown as UseBoundStore>; -} - -export function createStore(stateCreator: StateCreator): StoreApi { - if (typeof stateCreator === 'function') { - return createStoreUncurried(stateCreator); - } - return createStoreUncurried as unknown as StoreApi; -} - -afterEach(() => { - act(() => { - STORE_RESET_FUNCTIONS.forEach((resetFn) => { - resetFn(); - }); - }); -}); diff --git a/package.json b/package.json index d90c2902..b22d1dc5 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,11 @@ "types": "./dist/i18n.d.ts", "import": "./dist/i18n.js" }, + "./package.json": "./package.json", "./providers": { "types": "./dist/providers.d.ts", "import": "./dist/providers.js" }, - "./package.json": "./package.json", "./tailwind/globals.css": "./dist/tailwind/globals.css", "./utils": { "types": "./dist/utils.d.ts", @@ -97,6 +97,7 @@ "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", "@tanstack/react-table": "^8.21.3", + "@tanstack/table-core": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2981b6fd..4338fd44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/table-core': + specifier: ^8.21.3 + version: 8.21.3 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 diff --git a/src/components/DataTable/DataTable.stories.tsx b/src/components/DataTable/DataTable.stories.tsx index dd3b51f1..034a4d61 100644 --- a/src/components/DataTable/DataTable.stories.tsx +++ b/src/components/DataTable/DataTable.stories.tsx @@ -1,76 +1,246 @@ -import { range, toBasicISOString, unwrap } from '@douglasneuroinformatics/libjs'; +import { useState } from 'react'; + import { faker } from '@faker-js/faker'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ColumnDef } from '@tanstack/table-core'; +import { range } from 'lodash-es'; +import { ChevronDownIcon } from 'lucide-react'; +import { Button } from '../Button'; +import { DropdownMenu } from '../DropdownMenu'; import { DataTable } from './DataTable'; +import { useDataTableHandle } from './hooks'; -import type { DataTableColumn } from './DataTable'; +type PaymentStatus = 'failed' | 'pending' | 'processing' | 'success'; -type User = { - birthday: Date; +type Payment = { + amount: number; email: string; - firstName: string; - lastName: string; + id: string; + status: PaymentStatus; }; -type Story = StoryObj>; +type Story = StoryObj>; -const columns: DataTableColumn[] = [ +const columns: ColumnDef[] = [ { - format: (value) => { - return toBasicISOString(value); + accessorKey: 'status', + enableSorting: false, + filterFn: (row, id, filter: PaymentStatus[]) => { + return filter.includes(row.getValue(id)); }, - key: 'birthday', - label: 'Birthday' + header: 'Status' + }, + { + accessorKey: 'email', + header: 'Email' }, { - format: 'email', - key: 'email', - label: 'Email', - sortable: true + accessorKey: 'amount', + header: 'Amount' } ]; -const data: User[] = unwrap(range(60)).map(() => ({ - birthday: faker.date.birthdate(), - email: faker.internet.email(), - firstName: faker.person.firstName(), - lastName: faker.person.lastName() -})); +const statuses: readonly PaymentStatus[] = Object.freeze(['failed', 'pending', 'processing', 'success']); + +const createData = (n: number): Payment[] => { + return range(n).map((i) => ({ + amount: faker.number.int({ max: 100, min: 0 }), + email: faker.internet.email(), + id: String(i + 1), + status: faker.helpers.arrayElement(statuses) + })); +}; + +const Toggles = () => { + const table = useDataTableHandle('table', true); + const columns = table.getAllColumns(); + const statusColumn = columns.find((column) => column.id === 'status')!; + + const filterValue = statusColumn.getFilterValue() as PaymentStatus[]; + + return ( + <> + + + + + + {columns + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + column.toggleVisibility(!!value)} + > + {column.id} + + ); + })} + + + + + + + + {statuses.map((option) => ( + { + statusColumn.setFilterValue((prevValue: PaymentStatus[]) => { + if (checked) { + return [...prevValue, option]; + } + return prevValue.filter((item) => item !== option); + }); + }} + > + {option} + + ))} + + + + ); +}; -export default { component: DataTable } as Meta>; +export default { + component: DataTable +} as Meta; export const Default: Story = { + decorators: [ + (Story) => { + const [tableData, setTableData] = useState(createData(10)); + return ( +
+ +
+ +
+
+ ); + } + ] +}; + +export const WithActions: Story = { args: { - columns, - data, - headerActions: [ + columns: [ + ...columns, { - label: 'Do Something', - onClick: () => { - alert('Something!'); - } + accessorKey: 'notes', + header: 'Notes' } ], + data: createData(100).map((payment) => ({ ...payment, notes: faker.lorem.paragraph() })), + onSearchChange: () => { + return; + }, rowActions: [ + { + label: 'Modify', + onSelect: () => { + alert('Modify'); + } + }, { destructive: true, label: 'Delete', - onSelect: (row) => { - alert(`Delete User: ${row.firstName} ${row.lastName}`); + onSelect: () => { + alert('Delete'); } } ], - search: { - key: 'email', - placeholder: 'Filter emails...' - } + tableName: 'action-table' + } +}; + +export const WithToggles: Story = { + args: { + columns, + data: createData(100), + initialState: { + columnFilters: [ + { + id: 'status', + value: [...statuses] + } + ] + }, + onSearchChange: () => { + return; + }, + togglesComponent: Toggles } }; export const Empty: Story = { args: { columns, - data: [] + data: [], + onSearchChange: () => { + return; + } + } +}; + +export const Grouped: Story = { + args: { + columns: [ + { + columns: [ + { + accessorKey: 'id', + header: 'ID' + }, + { + accessorKey: 'status', + header: 'Status' + } + ], + header: 'Internal' + }, + { + columns: [ + { + accessorKey: 'email', + header: 'Email' + }, + { + accessorKey: 'amount', + header: 'Amount' + } + ], + header: 'Details' + } + ], + data: createData(100), + onSearchChange: () => { + return; + } } }; diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx index 8617c372..f41c26de 100644 --- a/src/components/DataTable/DataTable.tsx +++ b/src/components/DataTable/DataTable.tsx @@ -1,290 +1,33 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useRef } from 'react'; -import { - flexRender, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable -} from '@tanstack/react-table'; -import type { ColumnDef, ColumnFiltersState, SortingState } from '@tanstack/react-table'; -import { range } from 'lodash-es'; -import { ArrowUpDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon } from 'lucide-react'; -import type { Promisable } from 'type-fest'; +import type { RowData } from '@tanstack/table-core'; -import { useTranslation } from '@/hooks'; +import { DataTableContext } from './context'; +import { DataTableContent } from './DataTableContent'; +import { createDataTableStore } from './store'; -import { Button } from '../Button'; -import { SearchBar } from '../SearchBar'; -import { Table } from '../Table'; -import { DestructiveActionDialog } from './DestructiveActionDialog'; -import { RowActionsDropdown } from './RowActionsDropdown'; +import type { DataTableProps } from './types'; -import type { RowAction } from './RowActionsDropdown'; - -type StaticDataTableColumn = { - [K in Extract]: { - format?: 'email' | ((val: TData[K]) => unknown); - key: K; - label: string; - sortable?: boolean; - }; -}[Extract]; - -type DynamicDataTableColumn = { - compute: (row: TData) => unknown; - key?: never; - label: string; -}; - -type DataTableColumn = - | DynamicDataTableColumn - | StaticDataTableColumn; - -type DataTableProps = { - columns: DataTableColumn[]; - data: TData[]; - headerActions?: { - label: string; - onClick: () => void; - }[]; - rowActions?: RowAction[]; - search?: { - key: Extract; - placeholder?: string; - }; -}; - -function isStaticColumn( - column: DataTableColumn -): column is StaticDataTableColumn { - return typeof column.key === 'string'; -} - -export const DataTable = ({ - columns, - data, - headerActions, - rowActions, - search -}: DataTableProps) => { - const [columnFilters, setColumnFilters] = useState([]); - const [sorting, setSorting] = useState([]); - const [searchValue, setSearchValue] = useState(''); - const [destructiveActionPending, setDestructiveActionPending] = useState<(() => Promisable) | null>(null); - const { t } = useTranslation('libui'); - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 10 - }); - - const columnDefs = useMemo[]>(() => { - const result: ColumnDef[] = columns.map((col) => { - let def: ColumnDef; - if (isStaticColumn(col)) { - def = { - accessorKey: col.key, - header: col.sortable - ? ({ column }) => ( - - ) - : col.label - }; - if (col.format) { - def.cell = ({ getValue }) => { - const value = getValue() as TData[Extract]; - if (typeof col.format === 'function') { - return col.format(value); - } else if (col.format === 'email') { - return ( - - {value as string} - - ); - } - return value; - }; - } - } else { - def = { - accessorFn: col.compute, - header: col.label - }; - } - return def; - }); - - if (rowActions) { - result.push({ - cell: ({ row }) => { - return ( - - ); - }, - id: '__actions' - }); - } - return result; - }, [columns]); - - const table = useReactTable({ - columns: columnDefs, - data, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - onColumnFiltersChange: setColumnFilters, - onPaginationChange: setPagination, - onSortingChange: setSorting, - state: { - columnFilters, - pagination, - sorting - } - }); +export const DataTable = ({ + emptyStateProps, + onSearchChange, + togglesComponent, + ...props +}: DataTableProps) => { + const storeRef = useRef(createDataTableStore(props)); useEffect(() => { - if (search) { - table.getColumn(search.key)?.setFilterValue(searchValue); - } - }, [table, searchValue]); - - const headerGroups = table.getHeaderGroups(); - const { rows } = table.getRowModel(); - - const pageCount = table.getPageCount(); - const currentPage = pagination.pageIndex; - - const start = Math.max(0, Math.min(currentPage - 1, pageCount - 3)); - const end = Math.min(start + 3, pageCount); - - const pageIndexOptions = range(start, end); + const { reset } = storeRef.current.getState(); + reset(props); + }, [props]); return ( -
- + - {search && ( -
- - {headerActions && ( -
- {headerActions.map(({ label, onClick }, i) => ( - - ))} -
- )} -
- )} -
- - - {headerGroups.map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ); - })} - - ))} - - - {rows?.length ? ( - rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - {flexRender(cell.column.columnDef.cell, cell.getContext())} - ))} - - )) - ) : ( - - - {t({ - en: 'No Results', - fr: 'Aucun résultat' - })} - - - )} - -
-
-
- - - {pageIndexOptions.map((index) => ( - - ))} - - -
-
+ ); }; - -export type { DataTableColumn }; diff --git a/src/components/DataTable/DataTableBody.tsx b/src/components/DataTable/DataTableBody.tsx new file mode 100644 index 00000000..71e685d3 --- /dev/null +++ b/src/components/DataTable/DataTableBody.tsx @@ -0,0 +1,69 @@ +import { useTranslation } from '@/hooks'; + +import { DataTableEmptyState } from './DataTableEmptyState'; +import { useDataTableHandle } from './hooks'; +import { flexRender } from './utils'; + +import type { DataTableEmptyStateProps } from './DataTableEmptyState'; + +export const DataTableBody: React.FC<{ emptyStateProps?: Partial }> = ({ + emptyStateProps +}) => { + const rows = useDataTableHandle('rows'); + const { t } = useTranslation(); + + return ( +
+ {rows.length === 0 ? ( +
+ +
+ ) : ( + rows.map((row) => ( +
+ {row.getVisibleCells().map((cell) => { + const style: React.CSSProperties = { + width: `calc(var(--col-${cell.column.id}-size) * 1px)` + }; + if (cell.column.getIsPinned() === 'left') { + style.left = `${cell.column.getStart('left')}px`; + style.position = 'sticky'; + style.zIndex = 20; + } else if (cell.column.getIsPinned() === 'right') { + style.right = `${cell.column.getAfter('right')}px`; + style.position = 'sticky'; + style.zIndex = 20; + } + // no border with actions on right + // TODO - consider resizing toggle in this case + if (cell.column.getIsLastColumn('center')) { + style.borderRight = 'none'; + } + const content = flexRender(cell.column.columnDef.cell, cell.getContext()); + return ( +
+ {content && typeof content === 'object' ? content : {content}} +
+ ); + })} +
+ )) + )} +
+ ); +}; diff --git a/src/components/DataTable/DataTableContent.tsx b/src/components/DataTable/DataTableContent.tsx new file mode 100644 index 00000000..c17a4986 --- /dev/null +++ b/src/components/DataTable/DataTableContent.tsx @@ -0,0 +1,36 @@ +import type { RowData } from '@tanstack/table-core'; + +import { TABLE_NAME_METADATA_KEY } from './constants'; +import { DataTableBody } from './DataTableBody'; +import { DataTableControls } from './DataTableControls'; +import { DataTableHead } from './DataTableHead'; +import { DataTablePagination } from './DataTablePagination'; +import { useContainerRef, useDataTableHandle, useDataTableStore } from './hooks'; + +import type { DataTableContentProps } from './types'; + +export const DataTableContent = ({ + emptyStateProps, + onSearchChange, + togglesComponent +}: DataTableContentProps) => { + const containerRef = useContainerRef(); + const meta = useDataTableHandle('tableMeta'); + const style = useDataTableStore((state) => state.style); + return ( +
+ +
+
+ + +
+
+ +
+ ); +}; diff --git a/src/components/DataTable/DataTableControls.tsx b/src/components/DataTable/DataTableControls.tsx new file mode 100644 index 00000000..fc806051 --- /dev/null +++ b/src/components/DataTable/DataTableControls.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; + +import type { RowData, Table } from '@tanstack/table-core'; + +import { useTranslation } from '@/hooks'; + +import { SearchBar } from '../SearchBar'; +import { useDataTableHandle, useDataTableStore } from './hooks'; + +import type { SearchChangeHandler } from './types'; + +export const DataTableControls = ({ + onSearchChange, + togglesComponent: Toggles +}: { + onSearchChange?: SearchChangeHandler; + togglesComponent?: React.FC<{ table: Table }>; +}) => { + const table = useDataTableHandle('table'); + const setGlobalFilter = useDataTableStore((store) => store.setGlobalFilter); + const [searchValue, setSearchValue] = useState(''); + + const { t } = useTranslation(); + + useEffect(() => { + if (onSearchChange) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + onSearchChange(searchValue, table); + } else { + setGlobalFilter(searchValue || undefined); + } + }, [onSearchChange, searchValue]); + + return ( +
+ { + setSearchValue(value); + }} + /> + {Toggles && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/components/DataTable/DataTableEmptyState.tsx b/src/components/DataTable/DataTableEmptyState.tsx new file mode 100644 index 00000000..89f5bc74 --- /dev/null +++ b/src/components/DataTable/DataTableEmptyState.tsx @@ -0,0 +1,25 @@ +import type { LucideIcon } from 'lucide-react'; + +import { cn } from '@/utils'; + +export type DataTableEmptyStateProps = { + className?: string; + description?: string; + icon?: LucideIcon; + title: string; +}; + +export const DataTableEmptyState: React.FC = ({ + className, + description, + icon: Icon, + title +}) => { + return ( +
+ {Icon && } +

{title}

+ {description &&

{description}

} +
+ ); +}; diff --git a/src/components/DataTable/DataTableHead.tsx b/src/components/DataTable/DataTableHead.tsx new file mode 100644 index 00000000..c6e1d86f --- /dev/null +++ b/src/components/DataTable/DataTableHead.tsx @@ -0,0 +1,58 @@ +import { useDataTableHandle } from './hooks'; +import { flexRender } from './utils'; + +export const DataTableHead = () => { + const headerGroups = useDataTableHandle('headerGroups'); + const rowCount = useDataTableHandle('rowCount'); + + return ( +
+ {headerGroups.map((headerGroup) => ( +
+ {headerGroup.headers.map((header) => { + const style: React.CSSProperties = { + // TODO - add more robust solution - should be able to block centering also - also set correct typing + justifyContent: header.column.columnDef.meta?.centered ? 'center' : 'start', + width: `calc(var(--header-${header?.id}-size) * 1px)` + }; + if (header.column.getIsPinned() === 'left') { + style.left = `${header.column.getStart('left')}px`; + style.position = 'sticky'; + style.zIndex = 20; + } else if (header.column.getIsPinned() === 'right') { + style.right = `${header.column.getAfter('right')}px`; + style.position = 'sticky'; + style.zIndex = 20; + } + // no border with actions on right + // TODO - consider resizing toggle in this case + if (header.column.getIsLastColumn('center')) { + style.borderRight = 'none'; + } + return ( +
+ {!header.isPlaceholder && flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getCanResize() && ( +
+
+ )} +
+ ); + })} +
+ ))} +
+ ); +}; diff --git a/src/components/DataTable/DataTablePagination.tsx b/src/components/DataTable/DataTablePagination.tsx new file mode 100644 index 00000000..f9b4afaa --- /dev/null +++ b/src/components/DataTable/DataTablePagination.tsx @@ -0,0 +1,62 @@ +import { range } from 'lodash-es'; +import { ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon } from 'lucide-react'; + +import { Button } from '../Button'; +import { useDataTableHandle, useDataTableStore } from './hooks'; + +export const DataTablePagination = () => { + const { pageCount, pageIndex } = useDataTableHandle('paginationInfo'); + const setPageIndex = useDataTableStore((store) => store.setPageIndex); + + const start = Math.max(0, Math.min(pageIndex - 1, pageCount - 3)); + const end = Math.max(Math.min(start + 3, pageCount), 1); + + const pageIndexOptions = range(start, end); + const lastPageIndex = pageCount - 1; + + return ( +
+ + + {pageIndexOptions.map((index) => ( + + ))} + + +
+ ); +}; diff --git a/src/components/DataTable/DataTableRowActionCell.tsx b/src/components/DataTable/DataTableRowActionCell.tsx new file mode 100644 index 00000000..817b7b58 --- /dev/null +++ b/src/components/DataTable/DataTableRowActionCell.tsx @@ -0,0 +1,67 @@ +import type { CellContext, RowData } from '@tanstack/table-core'; +import { MoreHorizontalIcon } from 'lucide-react'; + +import { useDestructiveAction, useTranslation } from '@/hooks'; +import { cn } from '@/utils'; + +import { Button } from '../Button'; +import { DropdownMenu } from '../DropdownMenu'; +import { ROW_ACTIONS_METADATA_KEY, TABLE_NAME_METADATA_KEY } from './constants'; + +export const DataTableRowActionCell = ({ row, table }: CellContext) => { + const destructiveAction = useDestructiveAction(); + const rowActions = table.options.meta?.[ROW_ACTIONS_METADATA_KEY]; + const tableName = table.options.meta?.[TABLE_NAME_METADATA_KEY]; + + const { t } = useTranslation(); + + if (!rowActions) { + console.error('Expected rowActions to be defined in table metadata'); + return null; + } + + return ( +
+ + + + + + + {t({ + en: 'Actions', + fr: 'Actions' + })} + + {rowActions.map(({ destructive, disabled, label, onSelect }, i) => ( + { + if (destructive) { + destructiveAction(() => void onSelect(row.original, table)); + } else { + void onSelect(row.original, table); + } + }} + > + {label} + + ))} + + +
+ ); +}; diff --git a/src/components/DataTable/DestructiveActionDialog.tsx b/src/components/DataTable/DestructiveActionDialog.tsx deleted file mode 100644 index 3fd180f6..00000000 --- a/src/components/DataTable/DestructiveActionDialog.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* eslint-disable @typescript-eslint/no-misused-promises */ - -import type React from 'react'; - -import type { Promisable } from 'type-fest'; - -import { useTranslation } from '@/hooks'; - -import { Button } from '../Button'; -import { Dialog } from '../Dialog'; - -export type DestructiveActionPending = (() => Promisable) | null; - -export const DestructiveActionDialog: React.FC<{ - destructiveActionPending: DestructiveActionPending; - setDestructiveActionPending: React.Dispatch>; -}> = ({ destructiveActionPending, setDestructiveActionPending }) => { - const { t } = useTranslation(); - return ( - { - if (!open) { - setDestructiveActionPending(null); - } - }} - > - event.preventDefault()}> - - - {t({ - en: 'Confirm Action', - fr: "Confirmer l'action" - })} - - - {t({ - en: 'This action cannot be reversed. Please confirm that you would like to continue.', - fr: 'Cette action ne peut être inversée. Veuillez confirmer que vous souhaitez poursuivre.' - })} - - - - - - - - - ); -}; diff --git a/src/components/DataTable/RowActionsDropdown.tsx b/src/components/DataTable/RowActionsDropdown.tsx deleted file mode 100644 index eb17517c..00000000 --- a/src/components/DataTable/RowActionsDropdown.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type React from 'react'; - -import type { Row } from '@tanstack/react-table'; -import { MoreHorizontalIcon } from 'lucide-react'; -import type { Promisable } from 'type-fest'; - -import { useTranslation } from '@/hooks'; - -import { Button } from '../Button'; -import { DropdownMenu } from '../DropdownMenu'; - -import type { DestructiveActionPending } from './DestructiveActionDialog'; - -export type RowAction = { - destructive?: boolean; - label: string; - onSelect: (row: TData) => Promisable; -}; - -export const RowActionsDropdown = ({ - row, - rowActions, - setDestructiveActionPending -}: { - row: Row; - rowActions: RowAction[]; - setDestructiveActionPending: React.Dispatch>; -}) => { - const { t } = useTranslation(); - return ( -
- - - - - - - {t({ - en: 'Actions', - fr: 'Actions' - })} - - {rowActions.map(({ destructive, label, onSelect }, i) => ( - { - if (destructive) { - setDestructiveActionPending(() => () => void onSelect(row.original)); - } else { - void onSelect(row.original); - } - }} - > - {label} - - ))} - - -
- ); -}; diff --git a/src/components/DataTable/__tests__/DataTable.spec.tsx b/src/components/DataTable/__tests__/DataTable.spec.tsx new file mode 100644 index 00000000..347ac533 --- /dev/null +++ b/src/components/DataTable/__tests__/DataTable.spec.tsx @@ -0,0 +1,60 @@ +import { faker } from '@faker-js/faker'; +import type { ColumnDef } from '@tanstack/table-core'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { range } from 'lodash-es'; +import { describe, expect, it } from 'vitest'; + +import { DataTable } from '../DataTable'; + +type PaymentStatus = 'failed' | 'pending' | 'processing' | 'success'; + +type Payment = { + amount: number; + email: string; + id: string; + status: PaymentStatus; +}; + +const columns: ColumnDef[] = [ + { + accessorKey: 'status', + enableSorting: false, + filterFn: (row, id, filter: PaymentStatus[]) => { + return filter.includes(row.getValue(id)); + }, + header: 'Status' + }, + { + accessorKey: 'email', + header: 'Email' + }, + { + accessorKey: 'amount', + header: 'Amount' + } +]; + +const statuses: readonly PaymentStatus[] = Object.freeze(['failed', 'pending', 'processing', 'success']); + +const data: Payment[] = range(20).map((i) => ({ + amount: faker.number.int({ max: 100, min: 0 }), + email: faker.internet.email(), + id: String(i + 1), + status: faker.helpers.arrayElement(statuses) +})); + +describe('DataTable', () => { + it('should render with 10 visible rows', () => { + render(); + expect(screen.getByTestId('data-table')).toBeInTheDocument(); + expect(screen.getAllByTestId('data-table-row').length).toBe(10); + }); + it('should search', async () => { + render(); + const searchBar = screen.getByTestId('data-table-search-bar').querySelector('input')!; + fireEvent.change(searchBar, { target: { value: data[0]!.email } }); + await waitFor(() => expect(screen.getAllByTestId('data-table-row').length).toBe(1)); + fireEvent.change(searchBar, { target: { value: '' } }); + await waitFor(() => expect(screen.getAllByTestId('data-table-row').length).toBe(10)); + }); +}); diff --git a/src/components/DataTable/constants.ts b/src/components/DataTable/constants.ts new file mode 100644 index 00000000..ef85fff4 --- /dev/null +++ b/src/components/DataTable/constants.ts @@ -0,0 +1,7 @@ +export const ACTIONS_COLUMN_ID = '__actions' as const; + +export const MEMOIZED_HANDLE_ID = Symbol(); + +export const ROW_ACTIONS_METADATA_KEY = Symbol(); + +export const TABLE_NAME_METADATA_KEY = Symbol(); diff --git a/src/components/DataTable/context.ts b/src/components/DataTable/context.ts new file mode 100644 index 00000000..7cd0699d --- /dev/null +++ b/src/components/DataTable/context.ts @@ -0,0 +1,5 @@ +import { createContext } from 'react'; + +import type { DataTableStoreApi } from './store'; + +export const DataTableContext = createContext<{ store: DataTableStoreApi }>(null!); diff --git a/src/components/DataTable/hooks.ts b/src/components/DataTable/hooks.ts new file mode 100644 index 00000000..8fc59293 --- /dev/null +++ b/src/components/DataTable/hooks.ts @@ -0,0 +1,60 @@ +import { useContext, useEffect, useRef } from 'react'; + +import { useStore } from 'zustand'; +import { useStoreWithEqualityFn } from 'zustand/traditional'; + +import { MEMOIZED_HANDLE_ID } from './constants'; +import { DataTableContext } from './context'; + +import type { DataTableStore } from './types'; + +export function useContainerRef() { + const containerRef = useRef(null); + const setContainerWidth = useDataTableStore((state) => state.setContainerWidth); + + useEffect(() => { + let timeout: ReturnType; + let delay = 0; + + const observer = new ResizeObserver(([entry]) => { + clearTimeout(timeout); + timeout = setTimeout(() => { + delay = 100; + if (entry?.contentBoxSize[0]?.inlineSize) { + const containerWidth = entry.contentBoxSize[0].inlineSize; + setContainerWidth(containerWidth); + } + }, delay); + }); + + if (containerRef.current) { + observer.observe(containerRef.current); + } + return () => { + observer.disconnect(); + clearTimeout(timeout); + }; + }, []); + + return containerRef; +} + +export function useDataTableStore(selector: (state: DataTableStore) => T) { + const context = useContext(DataTableContext); + return useStore(context.store, selector); +} + +export function useDataTableHandle(key: TKey, forceRender = false) { + const context = useContext(DataTableContext); + const { handle } = useStoreWithEqualityFn( + context.store, + // the function is already updated by the time of equality check, so we cache it here + (store) => ({ + globalKey: store._key, + handle: store.$handles[key], + handleKey: store.$handles[key][MEMOIZED_HANDLE_ID] + }), + forceRender ? (a, b) => a.globalKey === b.globalKey : (a, b) => a.handleKey === b.handleKey + ); + return handle(); +} diff --git a/src/components/DataTable/store.ts b/src/components/DataTable/store.ts new file mode 100644 index 00000000..8c4d7d7b --- /dev/null +++ b/src/components/DataTable/store.ts @@ -0,0 +1,203 @@ +import { + createTable, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel +} from '@tanstack/table-core'; +import type { GlobalFilter, TableState, Updater } from '@tanstack/table-core'; +import { createStore } from 'zustand'; + +import { ROW_ACTIONS_METADATA_KEY, TABLE_NAME_METADATA_KEY } from './constants'; +import { + applyUpdater, + calculateColumnSizing, + defineMemoizedHandle, + getColumnsWithActions, + getTanstackTableState +} from './utils'; + +import type { DataTableStore, DataTableStoreParams } from './types'; + +export function createDataTableStore(params: DataTableStoreParams) { + return createStore((set, get) => { + const _state = getTanstackTableState(params); + + const invalidateHandles = (keys: TKey[] | void) => { + set((state) => { + (keys ?? (Object.keys(state.$handles) as TKey[])).forEach((key) => { + state.$handles[key].invalidate(); + }); + return { _key: Symbol() }; + }); + }; + + const setTableState = (key: TKey, updaterOrValue: Updater) => { + const state = table.getState(); + const value = applyUpdater(updaterOrValue, state[key]); + table.setOptions((prev) => ({ ...prev, state: { ...prev.state, [key]: value } })); + }; + + const updateColumnSizing = () => { + const { _containerWidth } = get(); + if (!_containerWidth) { + return; + } + setTableState('columnSizing', calculateColumnSizing(table, _containerWidth)); + }; + + const updateStyle = () => { + set((state) => { + const headers = table.getFlatHeaders(); + const style: React.CSSProperties & { [key: string]: any } = { + width: table.getTotalSize() + }; + if (state._containerWidth === null) { + style['--table-container-width'] = state._containerWidth; + style.visibility = 'hidden'; + } else { + style['--table-container-width'] = state._containerWidth; + style.visibility = 'visible'; + } + for (const header of headers) { + style[`--header-${header.id}-size`] = header.getSize(); + style[`--col-${header.column.id}-size`] = header.column.getSize(); + } + return { style }; + }); + }; + + const table = createTable({ + columnResizeMode: 'onChange', + columns: getColumnsWithActions(params), + data: params.data, + enableSortingRemoval: false, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + meta: { + ...params.meta, + [ROW_ACTIONS_METADATA_KEY]: params.rowActions, + [TABLE_NAME_METADATA_KEY]: params.tableName + }, + onColumnFiltersChange: (updaterOrValue) => { + setTableState('columnFilters', updaterOrValue); + invalidateHandles(); + }, + onColumnPinningChange: (updaterOrValue) => { + setTableState('columnPinning', updaterOrValue); + invalidateHandles(); + }, + onColumnSizingChange: (updaterOrValue) => { + const { _containerWidth: containerWidth } = get(); + const { columnSizing: prevColumnSizing } = table.getState(); + if (!containerWidth) { + console.error('Cannot set column sizing: container width is null'); + return; + } + const updatedColumnSizing = applyUpdater(updaterOrValue, prevColumnSizing); + const computedWidth = table.getVisibleLeafColumns().reduce((previous, current) => { + return previous + (updatedColumnSizing[current.id] ?? current.getSize()); + }, 0); + if (Number.isNaN(computedWidth)) { + console.error('Failed to compute width for columns'); + return; + } + if (containerWidth > computedWidth) { + return; + } + setTableState('columnSizing', updatedColumnSizing); + updateStyle(); + invalidateHandles(); + }, + onColumnSizingInfoChange: (updaterOrValue) => { + setTableState('columnSizingInfo', updaterOrValue); + updateStyle(); + invalidateHandles(); + }, + onColumnVisibilityChange: (updaterOrValue) => { + setTableState('columnVisibility', updaterOrValue); + updateColumnSizing(); + updateStyle(); + invalidateHandles(); + }, + onGlobalFilterChange: (updaterOrValue: Updater) => { + setTableState('globalFilter', updaterOrValue); + invalidateHandles(); + }, + onPaginationChange: (updaterOrValue) => { + setTableState('pagination', updaterOrValue); + invalidateHandles(); + }, + onSortingChange: (updaterOrValue) => { + setTableState('sorting', updaterOrValue); + invalidateHandles(); + }, + onStateChange: (updaterOrValue) => { + const prevState = table.getState(); + table.setOptions((prev) => ({ + ...prev, + state: typeof updaterOrValue === 'function' ? updaterOrValue(prevState) : updaterOrValue + })); + invalidateHandles(); + }, + renderFallbackValue: null, + state: _state + }); + + return { + $handles: { + headerGroups: defineMemoizedHandle(() => table.getHeaderGroups()), + paginationInfo: defineMemoizedHandle(() => { + const { pagination } = table.getState(); + return { + pageCount: table.getPageCount(), + pageIndex: pagination.pageIndex + }; + }), + rowCount: defineMemoizedHandle(() => table.getRowCount()), + rows: defineMemoizedHandle(() => { + const { rows } = table.getRowModel(); + return rows; + }), + table: defineMemoizedHandle(() => table), + tableMeta: defineMemoizedHandle(() => table.options.meta ?? {}) + }, + _containerWidth: null, + _key: Symbol(), + reset: (params) => { + table.setOptions((options) => ({ + ...options, + columns: getColumnsWithActions(params), + data: params.data, + meta: { + ...params.meta, + [ROW_ACTIONS_METADATA_KEY]: params.rowActions, + [TABLE_NAME_METADATA_KEY]: params.tableName + }, + state: getTanstackTableState(params) + })); + invalidateHandles(); + }, + setContainerWidth: (containerWidth) => { + set(() => { + return { _containerWidth: containerWidth }; + }); + updateColumnSizing(); + updateStyle(); + }, + setGlobalFilter: (globalFilter) => { + table.setGlobalFilter(globalFilter); + }, + setPageIndex: (index) => { + table.setPageIndex(index); + }, + style: { + visibility: 'hidden' + } + }; + }); +} + +export type DataTableStoreApi = ReturnType; diff --git a/src/components/DataTable/types.ts b/src/components/DataTable/types.ts new file mode 100644 index 00000000..bf7e1c5c --- /dev/null +++ b/src/components/DataTable/types.ts @@ -0,0 +1,99 @@ +import type { + ColumnDef, + ColumnFiltersState, + ColumnPinningState, + GlobalFilter, + HeaderGroup, + Row, + RowData, + SortingState, + Table, + TableMeta +} from '@tanstack/table-core'; +import type { Promisable } from 'type-fest'; + +import type { MEMOIZED_HANDLE_ID, ROW_ACTIONS_METADATA_KEY, TABLE_NAME_METADATA_KEY } from './constants'; +import type { DataTableEmptyStateProps } from './DataTableEmptyState'; + +declare module '@tanstack/table-core' { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-unused-vars + export interface ColumnMeta { + [key: string]: unknown; + } + + export type GlobalFilter = string | undefined; + + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + export interface TableMeta { + [key: string]: unknown; + [ROW_ACTIONS_METADATA_KEY]?: DataTableRowAction[]; + [TABLE_NAME_METADATA_KEY]?: string; + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + export interface TableState { + globalFilter: GlobalFilter; + } +} + +export type DataTableInitialState = { + columnFilters?: ColumnFiltersState; + columnPinning?: ColumnPinningState; + sorting?: SortingState; +}; + +export type DataTableProps = DataTableContentProps & DataTableStoreParams; + +export type DataTableContentProps = { + emptyStateProps?: Partial; + onSearchChange?: SearchChangeHandler>; + togglesComponent?: React.FC<{ table: Table }>; +}; + +export type DataTableHandles = { + [K in keyof T]: MemoizedHandle<() => T[K]>; +}; + +export type DataTableRowAction = { + destructive?: boolean; + disabled?: ((row: T) => boolean) | boolean; + label: string; + onSelect: (row: T, table: Table) => Promisable; +}; + +export type DataTableStore = { + $handles: DataTableHandles<{ + headerGroups: HeaderGroup[]; + paginationInfo: { + pageCount: number; + pageIndex: number; + }; + rowCount: number; + rows: Row[]; + table: Table; + tableMeta: TableMeta; + }>; + _containerWidth: null | number; + _key: symbol; + reset: (params: DataTableStoreParams) => void; + setContainerWidth: (containerWidth: number) => void; + setGlobalFilter: (globalFilter: GlobalFilter) => void; + setPageIndex: (index: number) => void; + style: React.CSSProperties; +}; + +export type DataTableStoreParams = { + columns: ColumnDef>[]; + data: T[]; + initialState?: DataTableInitialState; + meta?: TableMeta>; + rowActions?: DataTableRowAction>[]; + tableName?: string; +}; + +export type MemoizedHandle any> = T & { + invalidate(): void; + [MEMOIZED_HANDLE_ID]: symbol; +}; + +export type SearchChangeHandler = (value: string, table: Table) => void; diff --git a/src/components/DataTable/utils.tsx b/src/components/DataTable/utils.tsx new file mode 100644 index 00000000..d9580783 --- /dev/null +++ b/src/components/DataTable/utils.tsx @@ -0,0 +1,138 @@ +import type { ColumnDef, ColumnSizingState, RowData, Table, TableState, Updater } from '@tanstack/table-core'; +import { sum } from 'lodash-es'; + +import { ACTIONS_COLUMN_ID, MEMOIZED_HANDLE_ID } from './constants'; +import { DataTableRowActionCell } from './DataTableRowActionCell'; + +import type { DataTableStoreParams, MemoizedHandle } from './types'; + +function applyUpdater(updater: Updater, current: T): T { + return typeof updater === 'function' ? (updater as (prev: T) => T)(current) : updater; +} + +function calculateColumnSizing(table: Table, containerWidth: number) { + const updatedColumnSizing: ColumnSizingState = {}; + + const visibleCenterLeafColumns = table.getCenterLeafColumns().filter((column) => column.getIsVisible()); + const visibleCenterLeafColumnIds = visibleCenterLeafColumns.map((column) => column.id); + const visibleNonCenteredLeafColumns = table.getVisibleLeafColumns().filter((column) => { + return !visibleCenterLeafColumnIds.includes(column.id); + }); + + visibleNonCenteredLeafColumns.forEach((column) => { + const defaultSize = column.columnDef.size; + if (!defaultSize) { + console.error(`Size must be specified for pinned column with ID '${column.id}', defaulting to 200px`); + updatedColumnSizing[column.id] = 200; + } else { + updatedColumnSizing[column.id] = defaultSize; + } + }); + + const nonCenteredColumnsSize = sum(Object.values(updatedColumnSizing)); + const availableCenterSize = containerWidth - nonCenteredColumnsSize; + let maxCenterColumns: number; + if (containerWidth < 512) { + maxCenterColumns = 1; + } else if (containerWidth < 768) { + maxCenterColumns = 2; + } else if (containerWidth < 1024) { + maxCenterColumns = 3; + } else if (containerWidth < 1280) { + maxCenterColumns = 4; + } else { + maxCenterColumns = 5; + } + + const centerColumnsToDisplay = Math.min(visibleCenterLeafColumns.length, maxCenterColumns); + if (centerColumnsToDisplay) { + visibleCenterLeafColumns.forEach((column) => { + updatedColumnSizing[column.id] = availableCenterSize / centerColumnsToDisplay; + }); + } else { + visibleNonCenteredLeafColumns.forEach((column) => { + updatedColumnSizing[column.id] = containerWidth / visibleNonCenteredLeafColumns.length; + }); + } + + return updatedColumnSizing; +} + +function defineMemoizedHandle any>(target: T) { + const handle = target as MemoizedHandle; + handle[MEMOIZED_HANDLE_ID] = Symbol(); + handle.invalidate = function () { + this[MEMOIZED_HANDLE_ID] = Symbol(); + }; + return handle; +} + +function flexRender( + Comp: React.ComponentType | React.ReactNode, + props: TProps +): React.JSX.Element | React.ReactNode { + return !Comp ? null : isReactComponent(Comp) ? : Comp; +} + +function getColumnsWithActions({ columns, rowActions }: DataTableStoreParams): ColumnDef[] { + if (!rowActions) { + return columns; + } + return [ + ...columns, + { + cell: DataTableRowActionCell, + enableHiding: false, + enableResizing: false, + id: ACTIONS_COLUMN_ID, + size: 64 + } + ]; +} + +function getTanstackTableState({ initialState, rowActions }: DataTableStoreParams): TableState { + const { columnFilters = [], columnPinning = {}, sorting = [] } = initialState ?? {}; + const state: TableState = { + columnFilters, + columnOrder: [], + columnPinning, + columnSizing: {}, + columnSizingInfo: { + columnSizingStart: [], + deltaOffset: null, + deltaPercentage: null, + isResizingColumn: false, + startOffset: null, + startSize: null + }, + columnVisibility: {}, + expanded: {}, + globalFilter: undefined, + grouping: [], + pagination: { + pageIndex: 0, + pageSize: 10 + }, + rowPinning: {}, + rowSelection: {}, + sorting + }; + if (rowActions) { + state.columnPinning.right ??= []; + state.columnPinning.right.push(ACTIONS_COLUMN_ID); + } + return state; +} + +function isReactComponent(component: unknown): component is React.ComponentType { + return typeof component === 'function'; +} + +export { + applyUpdater, + calculateColumnSizing, + defineMemoizedHandle, + flexRender, + getColumnsWithActions, + getTanstackTableState +}; diff --git a/src/hooks/useDestructiveAction/useDestructiveActionStore.test.ts b/src/hooks/useDestructiveAction/useDestructiveActionStore.test.ts index 25729945..6c8b7dfe 100644 --- a/src/hooks/useDestructiveAction/useDestructiveActionStore.test.ts +++ b/src/hooks/useDestructiveAction/useDestructiveActionStore.test.ts @@ -1,18 +1,13 @@ import { act, renderHook } from '@testing-library/react'; -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import * as zustand from 'zustand'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { useDestructiveActionStore } from './useDestructiveActionStore'; import type { DestructiveAction, DestructiveActionOptions } from './useDestructiveActionStore'; describe('useDestructiveActionStore', () => { - beforeAll(() => { - vi.spyOn(zustand, 'create'); - }); - afterEach(() => { - vi.clearAllMocks(); + useDestructiveActionStore.setState(useDestructiveActionStore.getInitialState()); }); it('should render and return an object', () => { diff --git a/src/hooks/useNotificationsStore/useNotificationsStore.test.ts b/src/hooks/useNotificationsStore/useNotificationsStore.test.ts index 15045f4c..d3b2919b 100644 --- a/src/hooks/useNotificationsStore/useNotificationsStore.test.ts +++ b/src/hooks/useNotificationsStore/useNotificationsStore.test.ts @@ -1,16 +1,9 @@ import { act, renderHook } from '@testing-library/react'; -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import * as zustand from 'zustand'; +import { describe, expect, it } from 'vitest'; import { useNotificationsStore } from './useNotificationsStore'; describe('useNotificationsStore', () => { - beforeAll(() => { - vi.spyOn(zustand, 'create'); - }); - afterEach(() => { - vi.clearAllMocks(); - }); it('should render and return an object', () => { const { result } = renderHook(() => useNotificationsStore()); expect(result.current).toBeTypeOf('object'); diff --git a/src/testing/setup-tests.ts b/src/testing/setup-tests.ts index 641e1657..377ee78e 100644 --- a/src/testing/setup-tests.ts +++ b/src/testing/setup-tests.ts @@ -1,12 +1,10 @@ import { cleanup } from '@testing-library/react'; -import { afterEach, vi } from 'vitest'; +import { afterEach } from 'vitest'; import { i18n } from '@/i18n'; import '@testing-library/jest-dom/vitest'; -vi.mock('zustand'); - i18n.init({ translations: {} }); // Since we're not using vitest globals, we need to explicitly call cleanup()