@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.
- package/dist/components.d.ts +5 -48
- package/dist/components.js +1111 -738
- package/dist/components.js.map +1 -1
- package/dist/hooks.d.ts +4 -2
- package/dist/providers.js +1 -1
- package/dist/providers.js.map +1 -1
- package/dist/{types-9zYgx7C8.d.ts → types-CQ7qbFhC.d.ts} +57 -1
- package/package.json +3 -2
- package/src/components/DataTable/DataTable.stories.tsx +207 -37
- package/src/components/DataTable/DataTable.tsx +22 -279
- package/src/components/DataTable/DataTableBody.tsx +69 -0
- package/src/components/DataTable/DataTableContent.tsx +36 -0
- package/src/components/DataTable/DataTableControls.tsx +55 -0
- package/src/components/DataTable/DataTableEmptyState.tsx +25 -0
- package/src/components/DataTable/DataTableHead.tsx +58 -0
- package/src/components/DataTable/DataTablePagination.tsx +62 -0
- package/src/components/DataTable/DataTableRowActionCell.tsx +67 -0
- package/src/components/DataTable/__tests__/DataTable.spec.tsx +60 -0
- package/src/components/DataTable/constants.ts +7 -0
- package/src/components/DataTable/context.ts +5 -0
- package/src/components/DataTable/hooks.ts +60 -0
- package/src/components/DataTable/store.ts +203 -0
- package/src/components/DataTable/types.ts +99 -0
- package/src/components/DataTable/utils.tsx +138 -0
- package/src/components/Form/BooleanField/BooleanFieldRadio.tsx +6 -12
- package/src/components/Form/Form.stories.tsx +2 -0
- package/src/hooks/useDestructiveAction/useDestructiveActionStore.test.ts +2 -7
- package/src/hooks/useNotificationsStore/useNotificationsStore.test.ts +1 -8
- package/src/providers/CoreProvider/DestructiveActionDialog.tsx +1 -1
- package/src/testing/setup-tests.ts +1 -3
- package/src/components/DataTable/DestructiveActionDialog.tsx +0 -67
- package/src/components/DataTable/RowActionsDropdown.tsx +0 -64
|
@@ -1,290 +1,33 @@
|
|
|
1
|
-
import { useEffect,
|
|
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 {
|
|
5
|
+
import { DataTableContext } from './context';
|
|
6
|
+
import { DataTableContent } from './DataTableContent';
|
|
7
|
+
import { createDataTableStore } from './store';
|
|
17
8
|
|
|
18
|
-
import {
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
<
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
25
|
+
<DataTableContext.Provider value={{ store: storeRef.current }}>
|
|
26
|
+
<DataTableContent
|
|
27
|
+
emptyStateProps={emptyStateProps}
|
|
28
|
+
togglesComponent={togglesComponent}
|
|
29
|
+
onSearchChange={onSearchChange}
|
|
180
30
|
/>
|
|
181
|
-
|
|
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
|
+
};
|