diff --git a/package.json b/package.json index 626bf273..fc511997 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", + "@tanstack/react-table": "^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 b4fa653a..82be62d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.1.8 version: 1.1.8(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2121,6 +2124,19 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 + '@tanstack/react-table@8.21.3': + resolution: + { integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww== } + engines: { node: '>=12' } + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.21.3': + resolution: + { integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg== } + engines: { node: '>=12' } + '@testing-library/dom@10.4.0': resolution: { integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ== } @@ -8012,6 +8028,14 @@ snapshots: tailwindcss: 4.1.0 vite: 6.2.4(@types/node@22.13.17)(jiti@2.4.2)(lightningcss@1.29.2) + '@tanstack/react-table@8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@tanstack/table-core@8.21.3': {} + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.26.2 diff --git a/src/components/DataTable/DataTable.stories.tsx b/src/components/DataTable/DataTable.stories.tsx new file mode 100644 index 00000000..38afd808 --- /dev/null +++ b/src/components/DataTable/DataTable.stories.tsx @@ -0,0 +1,76 @@ +import { range, toBasicISOString } from '@douglasneuroinformatics/libjs'; +import { faker } from '@faker-js/faker'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { DataTable } from './DataTable'; + +import type { DataTableColumn } from './DataTable'; + +type User = { + birthday: Date; + email: string; + firstName: string; + lastName: string; +}; + +type Story = StoryObj>; + +const columns: DataTableColumn[] = [ + { + format: (value) => { + return toBasicISOString(value); + }, + key: 'birthday', + label: 'Birthday' + }, + { + format: 'email', + key: 'email', + label: 'Email', + sortable: true + } +]; + +const data: User[] = range(60) + .unwrap() + .map(() => ({ + birthday: faker.date.birthdate(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName() + })); + +export default { component: DataTable } as Meta>; + +export const Default: Story = { + args: { + columns, + data, + headerAction: { + label: 'Do Something', + onClick: () => { + alert('Something!'); + } + }, + rowActions: [ + { + destructive: true, + label: 'Delete', + onSelect: (row) => { + alert(`Delete User: ${row.firstName} ${row.lastName}`); + } + } + ], + search: { + key: 'email', + placeholder: 'Filter emails...' + } + } +}; + +export const Empty: Story = { + args: { + columns, + data: [] + } +}; diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx new file mode 100644 index 00000000..12aa1512 --- /dev/null +++ b/src/components/DataTable/DataTable.tsx @@ -0,0 +1,289 @@ +import { Fragment, useEffect, useMemo, useState } 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 { useTranslation } from '@/hooks'; + +import { Button } from '../Button'; +import { SearchBar } from '../SearchBar'; +import { Table } from '../Table'; +import { DestructiveActionDialog } from './DestructiveActionDialog'; +import { RowActionsDropdown } from './RowActionsDropdown'; + +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[]; + headerAction?: { + 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, + headerAction, + 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 + } + }); + + 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); + + return ( + + + {search && ( +
+ + {headerAction && ( + + )} +
+ )} +
+ + + {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/DestructiveActionDialog.tsx b/src/components/DataTable/DestructiveActionDialog.tsx new file mode 100644 index 00000000..bdc78b94 --- /dev/null +++ b/src/components/DataTable/DestructiveActionDialog.tsx @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +/* eslint-disable jsx-a11y/no-autofocus */ + +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); + } + }} + > + + + + {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 new file mode 100644 index 00000000..eb17517c --- /dev/null +++ b/src/components/DataTable/RowActionsDropdown.tsx @@ -0,0 +1,64 @@ +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/index.ts b/src/components/DataTable/index.ts new file mode 100644 index 00000000..89841a48 --- /dev/null +++ b/src/components/DataTable/index.ts @@ -0,0 +1 @@ +export * from './DataTable'; diff --git a/src/components/index.ts b/src/components/index.ts index ff18399b..a58c8966 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -33,7 +33,6 @@ export * from './ListboxDropdown'; export * from './MenuBar'; export * from './NotificationHub'; export * from './OneTimePasswordInput'; -export * from './Pagination'; export * from './Popover'; export * from './Progress'; export * from './RadioGroup'; diff --git a/src/i18n/translations/libui.json b/src/i18n/translations/libui.json index 103df399..4ab9f8d6 100644 --- a/src/i18n/translations/libui.json +++ b/src/i18n/translations/libui.json @@ -142,6 +142,10 @@ "en": "<< First", "fr": "<< Première" }, + "first": { + "en": "First", + "fr": "Première" + }, "info": { "en": "Showing {{first}} to {{last}} of {{total}} results", "fr": "Affichage de {{first}} à {{last}} sur {{total}} résultats" @@ -150,6 +154,10 @@ "en": "Last >>", "fr": "Dernière >>" }, + "last": { + "en": "Last", + "fr": "Dernière" + }, "next": { "en": "Next", "fr": "Suivant" @@ -164,5 +172,13 @@ "en": "Search...", "fr": "Rechercher..." } + }, + "yes": { + "en": "Yes", + "fr": "Oui" + }, + "no": { + "en": "No", + "fr": "Non" } }