@postxl/generators 1.11.6 → 1.12.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/backend-view/model-view-service.generator.js +4 -1
- package/dist/backend-view/model-view-service.generator.js.map +1 -1
- package/dist/backend-view/template/query.utils.test.ts +34 -0
- package/dist/backend-view/template/query.utils.ts +89 -0
- package/dist/frontend-admin/admin.generator.d.ts +9 -0
- package/dist/frontend-admin/admin.generator.js +19 -0
- package/dist/frontend-admin/admin.generator.js.map +1 -1
- package/dist/frontend-admin/generators/admin-sidebar.generator.js +1 -1
- package/dist/frontend-admin/generators/audit-log-sidebar.generator.js +107 -113
- package/dist/frontend-admin/generators/audit-log-sidebar.generator.js.map +1 -1
- package/dist/frontend-admin/generators/comment-sidebar.generator.d.ts +9 -0
- package/dist/frontend-admin/generators/comment-sidebar.generator.js +246 -0
- package/dist/frontend-admin/generators/comment-sidebar.generator.js.map +1 -0
- package/dist/frontend-admin/generators/detail-sidebar.generator.d.ts +9 -0
- package/dist/frontend-admin/generators/detail-sidebar.generator.js +148 -0
- package/dist/frontend-admin/generators/detail-sidebar.generator.js.map +1 -0
- package/dist/frontend-admin/generators/model-admin-page.generator.js +40 -6
- package/dist/frontend-admin/generators/model-admin-page.generator.js.map +1 -1
- package/dist/frontend-core/frontend.generator.js +2 -1
- package/dist/frontend-core/frontend.generator.js.map +1 -1
- package/dist/frontend-core/template/docs/LOGIN_PROCESS.d2 +372 -0
- package/dist/frontend-core/template/docs/LOGIN_PROCESS.md +214 -0
- package/dist/frontend-core/template/docs/LOGIN_PROCESS.svg +914 -0
- package/dist/frontend-core/template/src/components/admin/table-filter.stories.tsx +265 -0
- package/dist/frontend-core/template/src/components/admin/table-filter.tsx +224 -128
- package/dist/frontend-core/template/src/components/admin/table-view-panel.tsx +159 -0
- package/dist/frontend-core/template/src/context-providers/auth-context-provider.tsx +54 -43
- package/dist/frontend-core/template/src/hooks/use-table-view-config.tsx +249 -0
- package/dist/frontend-core/template/src/pages/dashboard/dashboard.page.tsx +5 -5
- package/dist/frontend-core/template/src/pages/error/auth-error.page.tsx +37 -0
- package/dist/frontend-core/template/src/pages/error/default-error.page.tsx +24 -40
- package/dist/frontend-core/template/src/pages/error/not-found-error.page.tsx +8 -10
- package/dist/frontend-core/template/src/pages/login/login.page.tsx +39 -13
- package/dist/frontend-core/template/src/pages/unauthorized/unauthorized.page.tsx +2 -2
- package/dist/frontend-core/template/src/routes/_auth-routes.tsx +21 -8
- package/dist/frontend-core/template/src/routes/auth-error.tsx +19 -0
- package/dist/frontend-core/template/vite.config.ts +5 -0
- package/dist/frontend-core/types/hook.d.ts +1 -1
- package/dist/frontend-tables/generators/model-table.generator.js +30 -5
- package/dist/frontend-tables/generators/model-table.generator.js.map +1 -1
- package/dist/types/template/query.types.ts +19 -1
- package/package.json +5 -5
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
FieldValueType,
|
|
5
5
|
FilterConfig,
|
|
6
6
|
FilterFieldName,
|
|
7
|
+
FilterOption,
|
|
7
8
|
FilterState,
|
|
8
9
|
FilterValue,
|
|
9
10
|
NumberFilter,
|
|
@@ -11,7 +12,7 @@ import {
|
|
|
11
12
|
} from '@types'
|
|
12
13
|
|
|
13
14
|
import { CalendarIcon, FilterX, TrashIcon } from 'lucide-react'
|
|
14
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
15
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
15
16
|
|
|
16
17
|
import { cn } from '@lib/utils'
|
|
17
18
|
import {
|
|
@@ -23,6 +24,8 @@ import {
|
|
|
23
24
|
Popover,
|
|
24
25
|
PopoverContent,
|
|
25
26
|
PopoverTrigger,
|
|
27
|
+
SlicerHierarchyItem,
|
|
28
|
+
SlicerFilterOption,
|
|
26
29
|
testId,
|
|
27
30
|
} from '@postxl/ui-components'
|
|
28
31
|
|
|
@@ -46,6 +49,9 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
46
49
|
const fieldConfig = config[field]
|
|
47
50
|
const { filterKey, valueType: fieldType } = fieldConfig
|
|
48
51
|
|
|
52
|
+
// Check if this is a hierarchy filter
|
|
53
|
+
const isHierarchyFilter = fieldConfig.kind === 'hierarchy'
|
|
54
|
+
|
|
49
55
|
// We exclude the current field from the filters passed to the query.
|
|
50
56
|
// This prevents the options from refetching (and resetting scroll position) when the user selects an item within the same filter.
|
|
51
57
|
const queryFilters = useMemo(() => {
|
|
@@ -101,20 +107,40 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
101
107
|
enabled: open,
|
|
102
108
|
})
|
|
103
109
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
[optionsData],
|
|
112
|
-
)
|
|
110
|
+
// Options from backend - same type for both flat and hierarchy (hierarchy has children)
|
|
111
|
+
const options = useMemo<FilterOption[]>(() => {
|
|
112
|
+
if (!optionsData || !Array.isArray(optionsData)) {
|
|
113
|
+
return []
|
|
114
|
+
}
|
|
115
|
+
return optionsData.filter((opt): opt is FilterOption => opt != null && 'value' in opt)
|
|
116
|
+
}, [optionsData])
|
|
113
117
|
|
|
114
118
|
const [searchQuery, setSearchQuery] = useState('')
|
|
115
119
|
|
|
120
|
+
// Expanded state for hierarchy filter
|
|
121
|
+
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
|
122
|
+
|
|
123
|
+
// Auto-expand single top-level item when hierarchy options load
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (isHierarchyFilter && options.length === 1 && options[0].children?.length) {
|
|
126
|
+
setExpandedIds(new Set([options[0].value]))
|
|
127
|
+
}
|
|
128
|
+
}, [isHierarchyFilter, options])
|
|
129
|
+
|
|
130
|
+
const toggleExpand = useCallback((id: string) => {
|
|
131
|
+
setExpandedIds((prev) => {
|
|
132
|
+
const next = new Set(prev)
|
|
133
|
+
if (next.has(id)) {
|
|
134
|
+
next.delete(id)
|
|
135
|
+
} else {
|
|
136
|
+
next.add(id)
|
|
137
|
+
}
|
|
138
|
+
return next
|
|
139
|
+
})
|
|
140
|
+
}, [])
|
|
141
|
+
|
|
116
142
|
// Check if this field uses StringFilter (all string-type fields including ID, relation, enum, discriminatedUnion)
|
|
117
|
-
const isStringFilter = fieldType === 'string'
|
|
143
|
+
const isStringFilter = fieldType === 'string' && !isHierarchyFilter
|
|
118
144
|
|
|
119
145
|
const filteredOptions = useMemo(() => {
|
|
120
146
|
if (!searchQuery) {
|
|
@@ -139,13 +165,17 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
139
165
|
if (fieldType === 'boolean') {
|
|
140
166
|
return (val as boolean[]).map(String)
|
|
141
167
|
}
|
|
168
|
+
if (fieldType === 'hierarchy') {
|
|
169
|
+
// Hierarchy filters use StringFilter format
|
|
170
|
+
return ((val as StringFilter).values || []).map(String)
|
|
171
|
+
}
|
|
142
172
|
if (isStringFilter) {
|
|
143
173
|
return ((val as StringFilter).values || []).map(String)
|
|
144
174
|
}
|
|
145
175
|
return val as string[]
|
|
146
176
|
}, [fieldConfig.filterKey, fieldType, filters, isStringFilter])
|
|
147
177
|
|
|
148
|
-
const selectedValues = new Set(value)
|
|
178
|
+
const selectedValues = useMemo(() => new Set(value), [value])
|
|
149
179
|
const areAllSelected = options.length > 0 && options.every((option) => selectedValues.has(option.value))
|
|
150
180
|
|
|
151
181
|
const areAllFilteredSelected =
|
|
@@ -185,48 +215,58 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
185
215
|
}
|
|
186
216
|
}, [open, filters, fieldType, fieldConfig.filterKey, isStringFilter])
|
|
187
217
|
|
|
188
|
-
const handleNumberChange = (
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
218
|
+
const handleNumberChange = useCallback(
|
|
219
|
+
(newValues: string[], newMin: string, newMax: string) => {
|
|
220
|
+
const values = newValues.length > 0 ? newValues.map((v) => (v === '' ? '' : Number(v))) : undefined
|
|
221
|
+
const min = newMin === '' ? null : Number(newMin)
|
|
222
|
+
const max = newMax === '' ? null : Number(newMax)
|
|
192
223
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const start = newStart === '' ? null : newStart
|
|
202
|
-
const end = newEnd === '' ? null : newEnd
|
|
203
|
-
|
|
204
|
-
if (!values && start === null && end === null) {
|
|
205
|
-
onChange(undefined)
|
|
206
|
-
} else {
|
|
207
|
-
onChange({ values, start, end })
|
|
208
|
-
}
|
|
209
|
-
}
|
|
224
|
+
if (!values && min === null && max === null) {
|
|
225
|
+
onChange(undefined)
|
|
226
|
+
} else {
|
|
227
|
+
onChange({ values, min, max })
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
[onChange],
|
|
231
|
+
)
|
|
210
232
|
|
|
211
|
-
const
|
|
212
|
-
newValues: string[],
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
233
|
+
const handleDateChange = useCallback(
|
|
234
|
+
(newValues: string[], newStart: string, newEnd: string) => {
|
|
235
|
+
const values = newValues.length > 0 ? newValues : undefined
|
|
236
|
+
const start = newStart === '' ? null : newStart
|
|
237
|
+
const end = newEnd === '' ? null : newEnd
|
|
238
|
+
|
|
239
|
+
if (!values && start === null && end === null) {
|
|
240
|
+
onChange(undefined)
|
|
241
|
+
} else {
|
|
242
|
+
onChange({ values, start, end })
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
[onChange],
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
const handleStringFilterChange = useCallback(
|
|
249
|
+
(newValues: string[], contains: string, startsWith: string, endsWith: string, exclude: string) => {
|
|
250
|
+
const values = newValues.length > 0 ? newValues : undefined
|
|
251
|
+
const containsVal = contains === '' ? null : contains
|
|
252
|
+
const startsWithVal = startsWith === '' ? null : startsWith
|
|
253
|
+
const endsWithVal = endsWith === '' ? null : endsWith
|
|
254
|
+
const excludeVal = exclude === '' ? null : exclude
|
|
255
|
+
|
|
256
|
+
if (!values && !containsVal && !startsWithVal && !endsWithVal && !excludeVal) {
|
|
257
|
+
onChange(undefined)
|
|
258
|
+
} else {
|
|
259
|
+
onChange({
|
|
260
|
+
values,
|
|
261
|
+
contains: containsVal,
|
|
262
|
+
startsWith: startsWithVal,
|
|
263
|
+
endsWith: endsWithVal,
|
|
264
|
+
exclude: excludeVal,
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
[onChange],
|
|
269
|
+
)
|
|
230
270
|
|
|
231
271
|
const commitMinMax = () => {
|
|
232
272
|
let nextMin = minVal
|
|
@@ -259,18 +299,51 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
259
299
|
}
|
|
260
300
|
|
|
261
301
|
// Unified callback for FilterItem to handle value changes
|
|
262
|
-
const handleValueChange = (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
302
|
+
const handleValueChange = useCallback(
|
|
303
|
+
(newSelected: Set<string>) => {
|
|
304
|
+
const newVals = Array.from(newSelected).map(String)
|
|
305
|
+
if (fieldType === 'number') {
|
|
306
|
+
handleNumberChange(newVals, minVal, maxVal)
|
|
307
|
+
} else if (fieldType === 'date') {
|
|
308
|
+
handleDateChange(newVals, minVal, maxVal)
|
|
309
|
+
} else if (fieldType === 'hierarchy') {
|
|
310
|
+
// Hierarchy uses StringFilter format but without text filters
|
|
311
|
+
onChange(newVals.length > 0 ? { values: newVals } : undefined)
|
|
312
|
+
} else if (isStringFilter) {
|
|
313
|
+
handleStringFilterChange(newVals, containsVal, startsWithVal, endsWithVal, excludeVal)
|
|
314
|
+
} else {
|
|
315
|
+
onChange(newVals)
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
[
|
|
319
|
+
fieldType,
|
|
320
|
+
isStringFilter,
|
|
321
|
+
minVal,
|
|
322
|
+
maxVal,
|
|
323
|
+
containsVal,
|
|
324
|
+
startsWithVal,
|
|
325
|
+
endsWithVal,
|
|
326
|
+
excludeVal,
|
|
327
|
+
handleNumberChange,
|
|
328
|
+
handleDateChange,
|
|
329
|
+
handleStringFilterChange,
|
|
330
|
+
onChange,
|
|
331
|
+
],
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
// Handle hierarchy option toggle
|
|
335
|
+
const handleHierarchySelect = useCallback(
|
|
336
|
+
(value: string) => {
|
|
337
|
+
const newSelected = new Set(selectedValues)
|
|
338
|
+
if (newSelected.has(value)) {
|
|
339
|
+
newSelected.delete(value)
|
|
340
|
+
} else {
|
|
341
|
+
newSelected.add(value)
|
|
342
|
+
}
|
|
343
|
+
handleValueChange(newSelected)
|
|
344
|
+
},
|
|
345
|
+
[selectedValues, handleValueChange],
|
|
346
|
+
)
|
|
274
347
|
|
|
275
348
|
return (
|
|
276
349
|
<div className="w-full flex flex-col" data-test-id={__e2e_test_id__}>
|
|
@@ -297,6 +370,8 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
297
370
|
} else if (fieldType === 'date') {
|
|
298
371
|
handleDateChange([], '', '')
|
|
299
372
|
}
|
|
373
|
+
} else if (fieldType === 'hierarchy') {
|
|
374
|
+
onChange(undefined)
|
|
300
375
|
} else if (isStringFilter) {
|
|
301
376
|
setContainsVal('')
|
|
302
377
|
setStartsWithVal('')
|
|
@@ -421,68 +496,92 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
421
496
|
<div className="w-full border-b border-b-border/70" />
|
|
422
497
|
</div>
|
|
423
498
|
|
|
424
|
-
{/* Select all / all search results button
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
499
|
+
{/* Select all / all search results button - hidden for hierarchy mode because "select all"
|
|
500
|
+
is ambiguous: would it select all items at every level, only top-level items (implicitly
|
|
501
|
+
selecting everything underneath), only visible/expanded items, or only leaf nodes? Each
|
|
502
|
+
interpretation has different filtering effects, so we omit it to avoid confusion. */}
|
|
503
|
+
{!isHierarchyFilter && (
|
|
504
|
+
<Button
|
|
505
|
+
variant="ghost"
|
|
506
|
+
size="xs"
|
|
507
|
+
className="w-full justify-start px-2 py-1 rounded-sm text-sm font-normal"
|
|
508
|
+
__e2e_test_id__={testId(__e2e_test_id__, 'select-all')}
|
|
509
|
+
onClick={() => {
|
|
510
|
+
let newVals: string[] = []
|
|
511
|
+
if (searchQuery.length > 0) {
|
|
512
|
+
const newSelected = new Set(selectedValues)
|
|
513
|
+
if (areAllFilteredSelected) {
|
|
514
|
+
filteredOptions.forEach((o) => newSelected.delete(o.value))
|
|
515
|
+
} else {
|
|
516
|
+
filteredOptions.forEach((o) => newSelected.add(o.value))
|
|
517
|
+
}
|
|
518
|
+
newVals = Array.from(newSelected)
|
|
519
|
+
} else if (areAllSelected) {
|
|
520
|
+
newVals = []
|
|
436
521
|
} else {
|
|
437
|
-
|
|
522
|
+
newVals = options.map((o) => o.value)
|
|
438
523
|
}
|
|
439
|
-
newVals = Array.from(newSelected)
|
|
440
|
-
} else if (areAllSelected) {
|
|
441
|
-
newVals = []
|
|
442
|
-
} else {
|
|
443
|
-
newVals = options.map((o) => o.value)
|
|
444
|
-
}
|
|
445
524
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
525
|
+
if (fieldType === 'number') {
|
|
526
|
+
handleNumberChange(newVals, minVal, maxVal)
|
|
527
|
+
} else if (fieldType === 'date') {
|
|
528
|
+
handleDateChange(newVals, minVal, maxVal)
|
|
529
|
+
} else if (isStringFilter) {
|
|
530
|
+
handleStringFilterChange(newVals, containsVal, startsWithVal, endsWithVal, excludeVal)
|
|
531
|
+
} else {
|
|
532
|
+
onChange(newVals)
|
|
533
|
+
}
|
|
534
|
+
}}
|
|
535
|
+
>
|
|
536
|
+
{searchQuery.length > 0 ? (
|
|
537
|
+
<Checkbox
|
|
538
|
+
readOnly
|
|
539
|
+
checked={isAnyFilteredSelected}
|
|
540
|
+
disabled={filteredOptions.length === 0}
|
|
541
|
+
label="Select Search Results"
|
|
542
|
+
className="pointer-events-none"
|
|
543
|
+
checkboxSize="sm"
|
|
544
|
+
variant={areAllFilteredSelected ? 'simple' : 'default'}
|
|
545
|
+
iconStyle={areAllFilteredSelected ? 'simple' : 'solo'}
|
|
546
|
+
checkIcon={areAllFilteredSelected ? 'check' : 'square'}
|
|
547
|
+
/>
|
|
548
|
+
) : (
|
|
549
|
+
<Checkbox
|
|
550
|
+
readOnly
|
|
551
|
+
checked={selectedValues.size > 0}
|
|
552
|
+
disabled={options.length === 0}
|
|
553
|
+
label="Select All"
|
|
554
|
+
className="pointer-events-none"
|
|
555
|
+
checkboxSize="sm"
|
|
556
|
+
variant={areAllSelected ? 'simple' : 'default'}
|
|
557
|
+
iconStyle={areAllSelected ? 'simple' : 'solo'}
|
|
558
|
+
checkIcon={areAllSelected ? 'check' : 'square'}
|
|
559
|
+
/>
|
|
560
|
+
)}
|
|
561
|
+
</Button>
|
|
562
|
+
)}
|
|
483
563
|
|
|
484
|
-
{/* Options list */}
|
|
485
|
-
{
|
|
564
|
+
{/* Options list - hierarchy tree view or flat list */}
|
|
565
|
+
{isHierarchyFilter ? (
|
|
566
|
+
options.length === 0 ? (
|
|
567
|
+
<div className="py-6 text-center text-sm">No options available.</div>
|
|
568
|
+
) : (
|
|
569
|
+
<div className="px-1 py-1 max-h-[200px] overflow-auto">
|
|
570
|
+
{options.map((option) => (
|
|
571
|
+
<SlicerHierarchyItem
|
|
572
|
+
key={option.value}
|
|
573
|
+
option={option as SlicerFilterOption<string>}
|
|
574
|
+
selectedValues={selectedValues}
|
|
575
|
+
inheritedSelected={false}
|
|
576
|
+
expandedIds={expandedIds}
|
|
577
|
+
onToggleExpand={toggleExpand}
|
|
578
|
+
onSelect={handleHierarchySelect}
|
|
579
|
+
searchTerm={searchQuery}
|
|
580
|
+
/>
|
|
581
|
+
))}
|
|
582
|
+
</div>
|
|
583
|
+
)
|
|
584
|
+
) : filteredOptions.length === 0 ? (
|
|
486
585
|
<div className="py-6 text-center text-sm">No results found.</div>
|
|
487
586
|
) : (
|
|
488
587
|
<div className="px-2 py-1 max-h-[200px] overflow-auto">
|
|
@@ -676,13 +775,14 @@ const FilterItem = ({
|
|
|
676
775
|
selectedValues,
|
|
677
776
|
onValueChange,
|
|
678
777
|
}: {
|
|
679
|
-
option:
|
|
778
|
+
option: FilterOption
|
|
680
779
|
isSelected: boolean
|
|
681
780
|
fieldType: FieldValueType
|
|
682
781
|
selectedValues: Set<string>
|
|
683
782
|
onValueChange: (newSelected: Set<string>) => void
|
|
684
783
|
}) => {
|
|
685
784
|
let displayLabel = option.label
|
|
785
|
+
const hasMatches = option.hasMatches ?? true
|
|
686
786
|
|
|
687
787
|
// needs to happen in frontend (and can't be directly provided by backend) because of localization
|
|
688
788
|
if (option.value !== '') {
|
|
@@ -708,11 +808,7 @@ const FilterItem = ({
|
|
|
708
808
|
}}
|
|
709
809
|
checkIcon="check"
|
|
710
810
|
checkboxSize="sm"
|
|
711
|
-
className={cn(
|
|
712
|
-
'whitespace-nowrap py-px text-sm',
|
|
713
|
-
!option.hasMatches && 'opacity-50',
|
|
714
|
-
option.value === '' && 'italic',
|
|
715
|
-
)}
|
|
811
|
+
className={cn('whitespace-nowrap py-px text-sm', !hasMatches && 'opacity-50', option.value === '' && 'italic')}
|
|
716
812
|
label={displayLabel}
|
|
717
813
|
/>
|
|
718
814
|
)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { Table } from '@tanstack/react-table'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
4
|
+
import { toast } from 'sonner'
|
|
5
|
+
|
|
6
|
+
import { useTableViewConfig, type TableViewState } from '@hooks/use-table-view-config'
|
|
7
|
+
import { DataGridViewMenu } from '@postxl/ui-components'
|
|
8
|
+
|
|
9
|
+
export type TableViewPanelProps<TData> = {
|
|
10
|
+
/** The model/table name to scope views to (e.g. 'user', 'publication'). */
|
|
11
|
+
model: string
|
|
12
|
+
/** The TanStack Table instance (from useDataGrid). */
|
|
13
|
+
table: Table<TData>
|
|
14
|
+
/** The current user's ID. */
|
|
15
|
+
currentUserId?: string
|
|
16
|
+
/** Whether the current user has admin privileges. */
|
|
17
|
+
isAdmin?: boolean
|
|
18
|
+
/** Current filter state — included when saving a view. */
|
|
19
|
+
filters?: Record<string, unknown>
|
|
20
|
+
/** Current sort state — included when saving a view. */
|
|
21
|
+
sort?: { field: string; direction: 'asc' | 'desc' }[]
|
|
22
|
+
/** Called when a view is applied and its filters should be restored. */
|
|
23
|
+
onFiltersChange?: (filters: Record<string, unknown>) => void
|
|
24
|
+
/** Called when a view is applied and its sort should be restored. */
|
|
25
|
+
onSortChange?: (sort: { field: string; direction: 'asc' | 'desc' }[]) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Connects the table view config hook to the DataGridViewMenu component.
|
|
30
|
+
*
|
|
31
|
+
* Handles:
|
|
32
|
+
* - Collecting current table state (filters, sort, column visibility/order/pinning)
|
|
33
|
+
* - Saving/updating views with automatic JSON serialization
|
|
34
|
+
* - Applying loaded views back to the table and page filter/sort state
|
|
35
|
+
* - Auto-restoring the previously active view on mount
|
|
36
|
+
* - Rename and delete operations
|
|
37
|
+
*/
|
|
38
|
+
export function TableViewPanel<TData>({
|
|
39
|
+
model,
|
|
40
|
+
table,
|
|
41
|
+
currentUserId,
|
|
42
|
+
isAdmin,
|
|
43
|
+
filters,
|
|
44
|
+
sort,
|
|
45
|
+
onFiltersChange,
|
|
46
|
+
onSortChange,
|
|
47
|
+
}: TableViewPanelProps<TData>) {
|
|
48
|
+
const viewConfig = useTableViewConfig({ model, currentUserId })
|
|
49
|
+
const hasAutoAppliedRef = useRef(false)
|
|
50
|
+
|
|
51
|
+
const applyViewState = useCallback(
|
|
52
|
+
(viewId: string) => {
|
|
53
|
+
const view = viewConfig.views.find((v) => v.id === viewId)
|
|
54
|
+
if (!view) return
|
|
55
|
+
const state = viewConfig.parseViewState(view)
|
|
56
|
+
if (onFiltersChange) {
|
|
57
|
+
onFiltersChange(state.filters ?? {})
|
|
58
|
+
}
|
|
59
|
+
if (onSortChange) {
|
|
60
|
+
onSortChange(state.sorting ?? [])
|
|
61
|
+
}
|
|
62
|
+
if (state.columnVisibility) {
|
|
63
|
+
table.setColumnVisibility(state.columnVisibility)
|
|
64
|
+
}
|
|
65
|
+
if (state.columnOrder) {
|
|
66
|
+
table.setColumnOrder(state.columnOrder)
|
|
67
|
+
}
|
|
68
|
+
if (state.columnPinning) {
|
|
69
|
+
table.setColumnPinning(state.columnPinning)
|
|
70
|
+
}
|
|
71
|
+
if (state.columnSizing) {
|
|
72
|
+
table.setColumnSizing(state.columnSizing)
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
[viewConfig, onFiltersChange, onSortChange, table],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
// Auto-apply persisted view once views have loaded
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!viewConfig.isLoaded || hasAutoAppliedRef.current) return
|
|
81
|
+
hasAutoAppliedRef.current = true
|
|
82
|
+
|
|
83
|
+
if (viewConfig.activeViewId && viewConfig.views.find((v) => v.id === viewConfig.activeViewId)) {
|
|
84
|
+
applyViewState(viewConfig.activeViewId)
|
|
85
|
+
}
|
|
86
|
+
}, [viewConfig.isLoaded, viewConfig.activeViewId, viewConfig.views, applyViewState])
|
|
87
|
+
|
|
88
|
+
const collectTableState = useCallback(
|
|
89
|
+
(): TableViewState => ({
|
|
90
|
+
filters: filters,
|
|
91
|
+
sorting: sort,
|
|
92
|
+
columnVisibility: table.getState().columnVisibility,
|
|
93
|
+
columnOrder: table.getState().columnOrder,
|
|
94
|
+
columnPinning: table.getState().columnPinning as { left?: string[]; right?: string[] },
|
|
95
|
+
columnSizing: table.getState().columnSizing,
|
|
96
|
+
}),
|
|
97
|
+
[filters, sort, table],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const handleSaveView = useCallback(
|
|
101
|
+
({ name, isGlobal }: { name: string; isGlobal: boolean }) => {
|
|
102
|
+
void viewConfig.saveView({ name, isGlobal, state: collectTableState() }).then((view) => {
|
|
103
|
+
toast.success(`View "${view.name}" saved`)
|
|
104
|
+
})
|
|
105
|
+
},
|
|
106
|
+
[viewConfig, collectTableState],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const handleUpdateView = useCallback(
|
|
110
|
+
(viewId: string) => {
|
|
111
|
+
void viewConfig.updateView(viewId, collectTableState()).then(() => {
|
|
112
|
+
toast.success('View updated')
|
|
113
|
+
})
|
|
114
|
+
},
|
|
115
|
+
[viewConfig, collectTableState],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const handleApplyView = useCallback(
|
|
119
|
+
(viewId: string) => {
|
|
120
|
+
applyViewState(viewId)
|
|
121
|
+
viewConfig.setActiveViewId(viewId)
|
|
122
|
+
},
|
|
123
|
+
[applyViewState, viewConfig],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const handleRenameView = useCallback(
|
|
127
|
+
(viewId: string, name: string) => {
|
|
128
|
+
void viewConfig.renameView(viewId, name).then(() => {
|
|
129
|
+
toast.success(`View renamed to "${name}"`)
|
|
130
|
+
})
|
|
131
|
+
},
|
|
132
|
+
[viewConfig],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const handleDeleteView = useCallback(
|
|
136
|
+
(viewId: string) => {
|
|
137
|
+
void viewConfig.deleteView(viewId).then(() => {
|
|
138
|
+
toast.success('View deleted')
|
|
139
|
+
})
|
|
140
|
+
},
|
|
141
|
+
[viewConfig],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<DataGridViewMenu
|
|
146
|
+
table={table}
|
|
147
|
+
savedViews={viewConfig.savedViews}
|
|
148
|
+
activeViewId={viewConfig.activeViewId}
|
|
149
|
+
currentUserId={currentUserId}
|
|
150
|
+
isAdmin={isAdmin}
|
|
151
|
+
onApplyView={handleApplyView}
|
|
152
|
+
onSaveView={handleSaveView}
|
|
153
|
+
onUpdateView={handleUpdateView}
|
|
154
|
+
onRenameView={handleRenameView}
|
|
155
|
+
onDeleteView={handleDeleteView}
|
|
156
|
+
onClearView={viewConfig.clearActiveView}
|
|
157
|
+
/>
|
|
158
|
+
)
|
|
159
|
+
}
|