@douglasneuroinformatics/libui 4.9.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/components.d.ts +5 -48
  2. package/dist/components.js +1111 -738
  3. package/dist/components.js.map +1 -1
  4. package/dist/hooks.d.ts +4 -2
  5. package/dist/providers.js +1 -1
  6. package/dist/providers.js.map +1 -1
  7. package/dist/{types-9zYgx7C8.d.ts → types-CQ7qbFhC.d.ts} +57 -1
  8. package/package.json +3 -2
  9. package/src/components/DataTable/DataTable.stories.tsx +207 -37
  10. package/src/components/DataTable/DataTable.tsx +22 -279
  11. package/src/components/DataTable/DataTableBody.tsx +69 -0
  12. package/src/components/DataTable/DataTableContent.tsx +36 -0
  13. package/src/components/DataTable/DataTableControls.tsx +55 -0
  14. package/src/components/DataTable/DataTableEmptyState.tsx +25 -0
  15. package/src/components/DataTable/DataTableHead.tsx +58 -0
  16. package/src/components/DataTable/DataTablePagination.tsx +62 -0
  17. package/src/components/DataTable/DataTableRowActionCell.tsx +67 -0
  18. package/src/components/DataTable/__tests__/DataTable.spec.tsx +60 -0
  19. package/src/components/DataTable/constants.ts +7 -0
  20. package/src/components/DataTable/context.ts +5 -0
  21. package/src/components/DataTable/hooks.ts +60 -0
  22. package/src/components/DataTable/store.ts +203 -0
  23. package/src/components/DataTable/types.ts +99 -0
  24. package/src/components/DataTable/utils.tsx +138 -0
  25. package/src/components/Form/BooleanField/BooleanFieldRadio.tsx +6 -12
  26. package/src/components/Form/Form.stories.tsx +2 -0
  27. package/src/hooks/useDestructiveAction/useDestructiveActionStore.test.ts +2 -7
  28. package/src/hooks/useNotificationsStore/useNotificationsStore.test.ts +1 -8
  29. package/src/providers/CoreProvider/DestructiveActionDialog.tsx +1 -1
  30. package/src/testing/setup-tests.ts +1 -3
  31. package/src/components/DataTable/DestructiveActionDialog.tsx +0 -67
  32. package/src/components/DataTable/RowActionsDropdown.tsx +0 -64
@@ -1,290 +1,33 @@
1
- import { useEffect, useMemo, useState } from 'react';
1
+ import { useEffect, useRef } from 'react';
2
2
 
3
- import {
4
- flexRender,
5
- getCoreRowModel,
6
- getFilteredRowModel,
7
- getPaginationRowModel,
8
- getSortedRowModel,
9
- useReactTable
10
- } from '@tanstack/react-table';
11
- import type { ColumnDef, ColumnFiltersState, SortingState } from '@tanstack/react-table';
12
- import { range } from 'lodash-es';
13
- import { ArrowUpDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon } from 'lucide-react';
14
- import type { Promisable } from 'type-fest';
3
+ import type { RowData } from '@tanstack/table-core';
15
4
 
16
- import { useTranslation } from '@/hooks';
5
+ import { DataTableContext } from './context';
6
+ import { DataTableContent } from './DataTableContent';
7
+ import { createDataTableStore } from './store';
17
8
 
18
- import { Button } from '../Button';
19
- import { SearchBar } from '../SearchBar';
20
- import { Table } from '../Table';
21
- import { DestructiveActionDialog } from './DestructiveActionDialog';
22
- import { RowActionsDropdown } from './RowActionsDropdown';
9
+ import type { DataTableProps } from './types';
23
10
 
24
- import type { RowAction } from './RowActionsDropdown';
25
-
26
- type StaticDataTableColumn<TData extends { [key: string]: unknown }> = {
27
- [K in Extract<keyof TData, string>]: {
28
- format?: 'email' | ((val: TData[K]) => unknown);
29
- key: K;
30
- label: string;
31
- sortable?: boolean;
32
- };
33
- }[Extract<keyof TData, string>];
34
-
35
- type DynamicDataTableColumn<TData extends { [key: string]: unknown }> = {
36
- compute: (row: TData) => unknown;
37
- key?: never;
38
- label: string;
39
- };
40
-
41
- type DataTableColumn<TData extends { [key: string]: unknown }> =
42
- | DynamicDataTableColumn<TData>
43
- | StaticDataTableColumn<TData>;
44
-
45
- type DataTableProps<TData extends { [key: string]: unknown }> = {
46
- columns: DataTableColumn<TData>[];
47
- data: TData[];
48
- headerActions?: {
49
- label: string;
50
- onClick: () => void;
51
- }[];
52
- rowActions?: RowAction<TData>[];
53
- search?: {
54
- key: Extract<keyof TData, string>;
55
- placeholder?: string;
56
- };
57
- };
58
-
59
- function isStaticColumn<TData extends { [key: string]: unknown }>(
60
- column: DataTableColumn<TData>
61
- ): column is StaticDataTableColumn<TData> {
62
- return typeof column.key === 'string';
63
- }
64
-
65
- export const DataTable = <TData extends { [key: string]: unknown }>({
66
- columns,
67
- data,
68
- headerActions,
69
- rowActions,
70
- search
71
- }: DataTableProps<TData>) => {
72
- const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
73
- const [sorting, setSorting] = useState<SortingState>([]);
74
- const [searchValue, setSearchValue] = useState('');
75
- const [destructiveActionPending, setDestructiveActionPending] = useState<(() => Promisable<void>) | null>(null);
76
- const { t } = useTranslation('libui');
77
- const [pagination, setPagination] = useState({
78
- pageIndex: 0,
79
- pageSize: 10
80
- });
81
-
82
- const columnDefs = useMemo<ColumnDef<TData, unknown>[]>(() => {
83
- const result: ColumnDef<TData, unknown>[] = columns.map((col) => {
84
- let def: ColumnDef<TData, unknown>;
85
- if (isStaticColumn(col)) {
86
- def = {
87
- accessorKey: col.key,
88
- header: col.sortable
89
- ? ({ column }) => (
90
- <button
91
- className="flex items-center justify-between gap-2"
92
- onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
93
- >
94
- {col.label}
95
- <ArrowUpDownIcon className="h-4 w-4" />
96
- </button>
97
- )
98
- : col.label
99
- };
100
- if (col.format) {
101
- def.cell = ({ getValue }) => {
102
- const value = getValue() as TData[Extract<keyof TData, string>];
103
- if (typeof col.format === 'function') {
104
- return col.format(value);
105
- } else if (col.format === 'email') {
106
- return (
107
- <a className="hover:underline" href={`mailto:${value as string}`}>
108
- {value as string}
109
- </a>
110
- );
111
- }
112
- return value;
113
- };
114
- }
115
- } else {
116
- def = {
117
- accessorFn: col.compute,
118
- header: col.label
119
- };
120
- }
121
- return def;
122
- });
123
-
124
- if (rowActions) {
125
- result.push({
126
- cell: ({ row }) => {
127
- return (
128
- <RowActionsDropdown
129
- row={row}
130
- rowActions={rowActions}
131
- setDestructiveActionPending={setDestructiveActionPending}
132
- />
133
- );
134
- },
135
- id: '__actions'
136
- });
137
- }
138
- return result;
139
- }, [columns]);
140
-
141
- const table = useReactTable({
142
- columns: columnDefs,
143
- data,
144
- getCoreRowModel: getCoreRowModel(),
145
- getFilteredRowModel: getFilteredRowModel(),
146
- getPaginationRowModel: getPaginationRowModel(),
147
- getSortedRowModel: getSortedRowModel(),
148
- onColumnFiltersChange: setColumnFilters,
149
- onPaginationChange: setPagination,
150
- onSortingChange: setSorting,
151
- state: {
152
- columnFilters,
153
- pagination,
154
- sorting
155
- }
156
- });
11
+ export const DataTable = <T extends RowData>({
12
+ emptyStateProps,
13
+ onSearchChange,
14
+ togglesComponent,
15
+ ...props
16
+ }: DataTableProps<T>) => {
17
+ const storeRef = useRef(createDataTableStore(props));
157
18
 
158
19
  useEffect(() => {
159
- if (search) {
160
- table.getColumn(search.key)?.setFilterValue(searchValue);
161
- }
162
- }, [table, searchValue]);
163
-
164
- const headerGroups = table.getHeaderGroups();
165
- const { rows } = table.getRowModel();
166
-
167
- const pageCount = table.getPageCount();
168
- const currentPage = pagination.pageIndex;
169
-
170
- const start = Math.max(0, Math.min(currentPage - 1, pageCount - 3));
171
- const end = Math.min(start + 3, pageCount);
172
-
173
- const pageIndexOptions = range(start, end);
20
+ const { reset } = storeRef.current.getState();
21
+ reset(props);
22
+ }, [props]);
174
23
 
175
24
  return (
176
- <div className="flex flex-col">
177
- <DestructiveActionDialog
178
- destructiveActionPending={destructiveActionPending}
179
- setDestructiveActionPending={setDestructiveActionPending}
25
+ <DataTableContext.Provider value={{ store: storeRef.current }}>
26
+ <DataTableContent
27
+ emptyStateProps={emptyStateProps}
28
+ togglesComponent={togglesComponent}
29
+ onSearchChange={onSearchChange}
180
30
  />
181
- {search && (
182
- <div className="flex items-center gap-4 py-4">
183
- <SearchBar
184
- className="grow"
185
- placeholder={search.placeholder}
186
- value={searchValue}
187
- onValueChange={setSearchValue}
188
- />
189
- {headerActions && (
190
- <div className="flex gap-2">
191
- {headerActions.map(({ label, onClick }, i) => (
192
- <Button key={i} type="button" variant="outline" onClick={onClick}>
193
- {label}
194
- </Button>
195
- ))}
196
- </div>
197
- )}
198
- </div>
199
- )}
200
- <div className="rounded-md border">
201
- <Table>
202
- <Table.Header>
203
- {headerGroups.map((headerGroup) => (
204
- <Table.Row key={headerGroup.id}>
205
- {headerGroup.headers.map((header) => {
206
- return (
207
- <Table.Head key={header.id}>
208
- {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
209
- </Table.Head>
210
- );
211
- })}
212
- </Table.Row>
213
- ))}
214
- </Table.Header>
215
- <Table.Body>
216
- {rows?.length ? (
217
- rows.map((row) => (
218
- <Table.Row data-state={row.getIsSelected() && 'selected'} key={row.id}>
219
- {row.getVisibleCells().map((cell) => (
220
- <Table.Cell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</Table.Cell>
221
- ))}
222
- </Table.Row>
223
- ))
224
- ) : (
225
- <Table.Row>
226
- <Table.Cell className="h-24 text-center" colSpan={rowActions ? columns.length + 1 : columns.length}>
227
- {t({
228
- en: 'No Results',
229
- fr: 'Aucun résultat'
230
- })}
231
- </Table.Cell>
232
- </Table.Row>
233
- )}
234
- </Table.Body>
235
- </Table>
236
- </div>
237
- <div className="flex w-min gap-0.5 py-4 [&>button]:h-9">
238
- <Button
239
- disabled={!table.getCanPreviousPage()}
240
- size="icon"
241
- type="button"
242
- variant="ghost"
243
- onClick={() => table.firstPage()}
244
- >
245
- <ChevronsLeftIcon className="h-4 w-4" />
246
- </Button>
247
- <Button
248
- disabled={!table.getCanPreviousPage()}
249
- size="icon"
250
- type="button"
251
- variant="ghost"
252
- onClick={() => table.previousPage()}
253
- >
254
- <ChevronLeftIcon className="h-4 w-4" />
255
- </Button>
256
- {pageIndexOptions.map((index) => (
257
- <Button
258
- key={index}
259
- size="icon"
260
- type="button"
261
- variant={index === pagination.pageIndex ? 'outline' : 'ghost'}
262
- onClick={() => table.setPageIndex(index)}
263
- >
264
- {index + 1}
265
- </Button>
266
- ))}
267
- <Button
268
- disabled={!table.getCanNextPage()}
269
- size="icon"
270
- type="button"
271
- variant="ghost"
272
- onClick={() => table.nextPage()}
273
- >
274
- <ChevronRightIcon className="h-4 w-4" />
275
- </Button>
276
- <Button
277
- disabled={!table.getCanNextPage()}
278
- size="icon"
279
- type="button"
280
- variant="ghost"
281
- onClick={() => table.lastPage()}
282
- >
283
- <ChevronsRightIcon className="h-4 w-4" />
284
- </Button>
285
- </div>
286
- </div>
31
+ </DataTableContext.Provider>
287
32
  );
288
33
  };
289
-
290
- export type { DataTableColumn };
@@ -0,0 +1,69 @@
1
+ import { useTranslation } from '@/hooks';
2
+
3
+ import { DataTableEmptyState } from './DataTableEmptyState';
4
+ import { useDataTableHandle } from './hooks';
5
+ import { flexRender } from './utils';
6
+
7
+ import type { DataTableEmptyStateProps } from './DataTableEmptyState';
8
+
9
+ export const DataTableBody: React.FC<{ emptyStateProps?: Partial<DataTableEmptyStateProps> }> = ({
10
+ emptyStateProps
11
+ }) => {
12
+ const rows = useDataTableHandle('rows');
13
+ const { t } = useTranslation();
14
+
15
+ return (
16
+ <div className="flex flex-col" data-testid="data-table-body">
17
+ {rows.length === 0 ? (
18
+ <div
19
+ className="sticky left-0 flex h-72 items-center justify-center px-6 py-3"
20
+ style={{
21
+ width: 'calc(var(--table-container-width) * 1px)'
22
+ }}
23
+ >
24
+ <DataTableEmptyState
25
+ title={t({
26
+ en: 'No Results',
27
+ fr: 'Aucun résultat'
28
+ })}
29
+ {...emptyStateProps}
30
+ />
31
+ </div>
32
+ ) : (
33
+ rows.map((row) => (
34
+ <div className="flex border-b last:border-b-0" data-testid="data-table-row" id={row.id} key={row.id}>
35
+ {row.getVisibleCells().map((cell) => {
36
+ const style: React.CSSProperties = {
37
+ width: `calc(var(--col-${cell.column.id}-size) * 1px)`
38
+ };
39
+ if (cell.column.getIsPinned() === 'left') {
40
+ style.left = `${cell.column.getStart('left')}px`;
41
+ style.position = 'sticky';
42
+ style.zIndex = 20;
43
+ } else if (cell.column.getIsPinned() === 'right') {
44
+ style.right = `${cell.column.getAfter('right')}px`;
45
+ style.position = 'sticky';
46
+ style.zIndex = 20;
47
+ }
48
+ // no border with actions on right
49
+ // TODO - consider resizing toggle in this case
50
+ if (cell.column.getIsLastColumn('center')) {
51
+ style.borderRight = 'none';
52
+ }
53
+ const content = flexRender(cell.column.columnDef.cell, cell.getContext());
54
+ return (
55
+ <div
56
+ className="bg-background flex items-center border-r px-4 py-2 last:border-r-0"
57
+ key={cell.id}
58
+ style={style}
59
+ >
60
+ {content && typeof content === 'object' ? content : <span className="block truncate">{content}</span>}
61
+ </div>
62
+ );
63
+ })}
64
+ </div>
65
+ ))
66
+ )}
67
+ </div>
68
+ );
69
+ };
@@ -0,0 +1,36 @@
1
+ import type { RowData } from '@tanstack/table-core';
2
+
3
+ import { TABLE_NAME_METADATA_KEY } from './constants';
4
+ import { DataTableBody } from './DataTableBody';
5
+ import { DataTableControls } from './DataTableControls';
6
+ import { DataTableHead } from './DataTableHead';
7
+ import { DataTablePagination } from './DataTablePagination';
8
+ import { useContainerRef, useDataTableHandle, useDataTableStore } from './hooks';
9
+
10
+ import type { DataTableContentProps } from './types';
11
+
12
+ export const DataTableContent = <T extends RowData>({
13
+ emptyStateProps,
14
+ onSearchChange,
15
+ togglesComponent
16
+ }: DataTableContentProps<T>) => {
17
+ const containerRef = useContainerRef();
18
+ const meta = useDataTableHandle('tableMeta');
19
+ const style = useDataTableStore((state) => state.style);
20
+ return (
21
+ <div
22
+ className="bg-background flex w-full flex-col"
23
+ data-name={meta[TABLE_NAME_METADATA_KEY]}
24
+ data-testid="data-table"
25
+ >
26
+ <DataTableControls togglesComponent={togglesComponent} onSearchChange={onSearchChange} />
27
+ <div className="relative w-full overflow-auto rounded-md border" ref={containerRef}>
28
+ <div className="flex min-w-full flex-col text-sm tracking-tight" style={style}>
29
+ <DataTableHead />
30
+ <DataTableBody emptyStateProps={emptyStateProps} />
31
+ </div>
32
+ </div>
33
+ <DataTablePagination />
34
+ </div>
35
+ );
36
+ };
@@ -0,0 +1,55 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ import type { RowData, Table } from '@tanstack/table-core';
4
+
5
+ import { useTranslation } from '@/hooks';
6
+
7
+ import { SearchBar } from '../SearchBar';
8
+ import { useDataTableHandle, useDataTableStore } from './hooks';
9
+
10
+ import type { SearchChangeHandler } from './types';
11
+
12
+ export const DataTableControls = <T extends RowData>({
13
+ onSearchChange,
14
+ togglesComponent: Toggles
15
+ }: {
16
+ onSearchChange?: SearchChangeHandler<T>;
17
+ togglesComponent?: React.FC<{ table: Table<T> }>;
18
+ }) => {
19
+ const table = useDataTableHandle('table');
20
+ const setGlobalFilter = useDataTableStore((store) => store.setGlobalFilter);
21
+ const [searchValue, setSearchValue] = useState('');
22
+
23
+ const { t } = useTranslation();
24
+
25
+ useEffect(() => {
26
+ if (onSearchChange) {
27
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
28
+ onSearchChange(searchValue, table);
29
+ } else {
30
+ setGlobalFilter(searchValue || undefined);
31
+ }
32
+ }, [onSearchChange, searchValue]);
33
+
34
+ return (
35
+ <div className="flex flex-col items-center gap-4 pb-4 md:flex-row">
36
+ <SearchBar
37
+ className="w-full grow"
38
+ data-testid="data-table-search-bar"
39
+ placeholder={t({
40
+ en: 'Search...',
41
+ fr: 'Rechercher...'
42
+ })}
43
+ value={searchValue}
44
+ onValueChange={(value) => {
45
+ setSearchValue(value);
46
+ }}
47
+ />
48
+ {Toggles && (
49
+ <div className="flex w-full items-center gap-2 md:w-auto">
50
+ <Toggles table={table} />
51
+ </div>
52
+ )}
53
+ </div>
54
+ );
55
+ };
@@ -0,0 +1,25 @@
1
+ import type { LucideIcon } from 'lucide-react';
2
+
3
+ import { cn } from '@/utils';
4
+
5
+ export type DataTableEmptyStateProps = {
6
+ className?: string;
7
+ description?: string;
8
+ icon?: LucideIcon;
9
+ title: string;
10
+ };
11
+
12
+ export const DataTableEmptyState: React.FC<DataTableEmptyStateProps> = ({
13
+ className,
14
+ description,
15
+ icon: Icon,
16
+ title
17
+ }) => {
18
+ return (
19
+ <div className={cn('flex flex-col items-center justify-center', className)} data-testid="data-table-empty-state">
20
+ {Icon && <Icon className="text-muted-foreground mb-2" style={{ height: '20px', width: '20px' }} />}
21
+ <h3 className="text-foreground text-sm font-semibold">{title}</h3>
22
+ {description && <p className="text-muted-foreground mt-1 text-xs">{description}</p>}
23
+ </div>
24
+ );
25
+ };
@@ -0,0 +1,58 @@
1
+ import { useDataTableHandle } from './hooks';
2
+ import { flexRender } from './utils';
3
+
4
+ export const DataTableHead = () => {
5
+ const headerGroups = useDataTableHandle('headerGroups');
6
+ const rowCount = useDataTableHandle('rowCount');
7
+
8
+ return (
9
+ <div className="flex flex-col" data-testid="data-table-head" style={{ display: rowCount ? 'flex' : 'none' }}>
10
+ {headerGroups.map((headerGroup) => (
11
+ <div className="flex" key={headerGroup.id}>
12
+ {headerGroup.headers.map((header) => {
13
+ const style: React.CSSProperties = {
14
+ // TODO - add more robust solution - should be able to block centering also - also set correct typing
15
+ justifyContent: header.column.columnDef.meta?.centered ? 'center' : 'start',
16
+ width: `calc(var(--header-${header?.id}-size) * 1px)`
17
+ };
18
+ if (header.column.getIsPinned() === 'left') {
19
+ style.left = `${header.column.getStart('left')}px`;
20
+ style.position = 'sticky';
21
+ style.zIndex = 20;
22
+ } else if (header.column.getIsPinned() === 'right') {
23
+ style.right = `${header.column.getAfter('right')}px`;
24
+ style.position = 'sticky';
25
+ style.zIndex = 20;
26
+ }
27
+ // no border with actions on right
28
+ // TODO - consider resizing toggle in this case
29
+ if (header.column.getIsLastColumn('center')) {
30
+ style.borderRight = 'none';
31
+ }
32
+ return (
33
+ <div
34
+ className="group/cell bg-background relative flex items-center border-r border-b px-4 py-2 last:border-r-0"
35
+ key={header.id}
36
+ style={style}
37
+ >
38
+ {!header.isPlaceholder && flexRender(header.column.columnDef.header, header.getContext())}
39
+ {header.column.getCanResize() && (
40
+ <div className="absolute top-0 right-0 z-10 h-full w-[1px]">
41
+ <button
42
+ className="group-hover/cell:bg-border absolute -right-[1px] h-full w-full cursor-col-resize touch-none rounded-md bg-transparent select-none group-hover/cell:-right-[2px] group-hover/cell:w-[3px]"
43
+ style={{ transform: header.column.getIsLastColumn() ? 'translateX(-2px)' : undefined }}
44
+ type="button"
45
+ onDoubleClick={header.column.resetSize}
46
+ onMouseDown={header.getResizeHandler()}
47
+ onTouchStart={header.getResizeHandler()}
48
+ />
49
+ </div>
50
+ )}
51
+ </div>
52
+ );
53
+ })}
54
+ </div>
55
+ ))}
56
+ </div>
57
+ );
58
+ };
@@ -0,0 +1,62 @@
1
+ import { range } from 'lodash-es';
2
+ import { ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon } from 'lucide-react';
3
+
4
+ import { Button } from '../Button';
5
+ import { useDataTableHandle, useDataTableStore } from './hooks';
6
+
7
+ export const DataTablePagination = () => {
8
+ const { pageCount, pageIndex } = useDataTableHandle('paginationInfo');
9
+ const setPageIndex = useDataTableStore((store) => store.setPageIndex);
10
+
11
+ const start = Math.max(0, Math.min(pageIndex - 1, pageCount - 3));
12
+ const end = Math.max(Math.min(start + 3, pageCount), 1);
13
+
14
+ const pageIndexOptions = range(start, end);
15
+ const lastPageIndex = pageCount - 1;
16
+
17
+ return (
18
+ <div className="mx-auto flex w-min gap-0.5 py-4 [&>button]:h-9">
19
+ <Button disabled={pageIndex === 0} size="icon" type="button" variant="ghost" onClick={() => setPageIndex(0)}>
20
+ <ChevronsLeftIcon className="h-4 w-4" />
21
+ </Button>
22
+ <Button
23
+ disabled={pageIndex === 0}
24
+ size="icon"
25
+ type="button"
26
+ variant="ghost"
27
+ onClick={() => setPageIndex(pageIndex - 1)}
28
+ >
29
+ <ChevronLeftIcon className="h-4 w-4" />
30
+ </Button>
31
+ {pageIndexOptions.map((index) => (
32
+ <Button
33
+ key={index}
34
+ size="icon"
35
+ type="button"
36
+ variant={index === pageIndex ? 'outline' : 'ghost'}
37
+ onClick={() => setPageIndex(index)}
38
+ >
39
+ {index + 1}
40
+ </Button>
41
+ ))}
42
+ <Button
43
+ disabled={pageIndex === lastPageIndex}
44
+ size="icon"
45
+ type="button"
46
+ variant="ghost"
47
+ onClick={() => setPageIndex(pageIndex + 1)}
48
+ >
49
+ <ChevronRightIcon className="h-4 w-4" />
50
+ </Button>
51
+ <Button
52
+ disabled={pageIndex === lastPageIndex}
53
+ size="icon"
54
+ type="button"
55
+ variant="ghost"
56
+ onClick={() => setPageIndex(lastPageIndex)}
57
+ >
58
+ <ChevronsRightIcon className="h-4 w-4" />
59
+ </Button>
60
+ </div>
61
+ );
62
+ };
@@ -0,0 +1,67 @@
1
+ import type { CellContext, RowData } from '@tanstack/table-core';
2
+ import { MoreHorizontalIcon } from 'lucide-react';
3
+
4
+ import { useDestructiveAction, useTranslation } from '@/hooks';
5
+ import { cn } from '@/utils';
6
+
7
+ import { Button } from '../Button';
8
+ import { DropdownMenu } from '../DropdownMenu';
9
+ import { ROW_ACTIONS_METADATA_KEY, TABLE_NAME_METADATA_KEY } from './constants';
10
+
11
+ export const DataTableRowActionCell = <T extends RowData>({ row, table }: CellContext<T, unknown>) => {
12
+ const destructiveAction = useDestructiveAction();
13
+ const rowActions = table.options.meta?.[ROW_ACTIONS_METADATA_KEY];
14
+ const tableName = table.options.meta?.[TABLE_NAME_METADATA_KEY];
15
+
16
+ const { t } = useTranslation();
17
+
18
+ if (!rowActions) {
19
+ console.error('Expected rowActions to be defined in table metadata');
20
+ return null;
21
+ }
22
+
23
+ return (
24
+ <div className="flex h-full w-full items-center justify-center">
25
+ <DropdownMenu>
26
+ <DropdownMenu.Trigger asChild>
27
+ <Button
28
+ className="-m-1.5"
29
+ data-table-name={table.options.meta?.name}
30
+ data-testid="row-actions-trigger"
31
+ size="icon"
32
+ variant="ghost"
33
+ >
34
+ <MoreHorizontalIcon className="h-4 w-4" />
35
+ </Button>
36
+ </DropdownMenu.Trigger>
37
+ <DropdownMenu.Content align="end" data-table-name={tableName} data-testid="row-actions-dropdown">
38
+ <DropdownMenu.Label>
39
+ {t({
40
+ en: 'Actions',
41
+ fr: 'Actions'
42
+ })}
43
+ </DropdownMenu.Label>
44
+ {rowActions.map(({ destructive, disabled, label, onSelect }, i) => (
45
+ <DropdownMenu.Item
46
+ className={cn(
47
+ 'cursor-pointer data-[disabled]:pointer-events-auto data-[disabled]:cursor-not-allowed',
48
+ destructive && 'text-destructive'
49
+ )}
50
+ disabled={typeof disabled === 'function' ? disabled(row.original) : disabled}
51
+ key={i}
52
+ onSelect={() => {
53
+ if (destructive) {
54
+ destructiveAction(() => void onSelect(row.original, table));
55
+ } else {
56
+ void onSelect(row.original, table);
57
+ }
58
+ }}
59
+ >
60
+ {label}
61
+ </DropdownMenu.Item>
62
+ ))}
63
+ </DropdownMenu.Content>
64
+ </DropdownMenu>
65
+ </div>
66
+ );
67
+ };