@maestro-js/components 1.0.0-alpha.1 → 1.0.0-alpha.10
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/commands/add.ts +16 -1
- package/dist/components.json +1 -1
- package/dist/index.js +14 -1
- package/package.json +5 -5
- package/registry.json +610 -802
- package/scripts/build.ts +11 -4
- package/src/components/alert-dialog.tsx +3 -3
- package/src/components/avatar.tsx +1 -1
- package/src/components/button-link.tsx +3 -0
- package/src/components/button.tsx +4 -0
- package/src/components/checkbox.tsx +7 -7
- package/src/components/chip.tsx +7 -7
- package/src/components/container.tsx +3 -3
- package/src/components/date-range.tsx +161 -0
- package/src/components/dialog.tsx +21 -7
- package/src/components/disclosure.tsx +1 -1
- package/src/components/drawer.tsx +19 -5
- package/src/components/form.tsx +27 -5
- package/src/components/helpers/form-field.tsx +7 -8
- package/src/components/helpers/get-button-classes.ts +23 -23
- package/src/components/helpers/headless-button.tsx +1 -1
- package/src/components/inline-alert.tsx +3 -3
- package/src/components/labeled-value.tsx +2 -2
- package/src/components/menu.tsx +60 -26
- package/src/components/month-day-input.tsx +2 -1
- package/src/components/multiselect.tsx +74 -70
- package/src/components/optional-link.tsx +15 -0
- package/src/components/radio.tsx +8 -8
- package/src/components/select.tsx +9 -8
- package/src/components/stepper.tsx +6 -6
- package/src/components/switch.tsx +7 -7
- package/src/components/table/headless-templated-row-table.ts +550 -0
- package/src/components/table/index.tsx +41 -0
- package/src/components/table/server-table.ts +143 -0
- package/src/components/table/table-actions.tsx +76 -0
- package/src/components/table/table-container.tsx +173 -0
- package/src/components/table/table-container.type-test.ts +155 -0
- package/src/components/table/table-filters/date-range-filter.tsx +227 -0
- package/src/components/table/table-filters/select-filter.tsx +211 -0
- package/src/components/table/table-filters/sort.tsx +85 -0
- package/src/components/table/table-primitives.tsx +226 -0
- package/src/components/table/table-toolbar.tsx +300 -0
- package/src/components/table/table-types.ts +61 -0
- package/src/components/table/use-client-table.ts +157 -0
- package/src/components/table/use-client-table.type-test.ts +217 -0
- package/src/components/table/use-server-table.ts +192 -0
- package/src/components/table/use-server-table.type-test.ts +174 -0
- package/src/components/tabs.tsx +1 -1
- package/src/components/tag-field.tsx +3 -3
- package/src/components/text-area.tsx +1 -1
- package/src/components/toast.tsx +6 -6
- package/src/components/toggle-button-group.tsx +2 -2
- package/src/utils/icons.d.ts +2 -1
- package/src/utils/use-query-state.ts +626 -0
- package/src/utils/use-tab-indicator.ts +9 -9
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { HeadlessTemplatedRowTable } from './headless-templated-row-table'
|
|
3
|
+
import type { MatchSorterOptions } from 'match-sorter'
|
|
4
|
+
|
|
5
|
+
type ClientFeatures<
|
|
6
|
+
TData extends HeadlessTemplatedRowTable.RowData,
|
|
7
|
+
FC extends HeadlessTemplatedRowTable.FilterConfig<TData> = {},
|
|
8
|
+
SC extends HeadlessTemplatedRowTable.SortConfig<TData> = {}
|
|
9
|
+
> = [
|
|
10
|
+
ReturnType<typeof HeadlessTemplatedRowTable.rowCount<TData>>,
|
|
11
|
+
ReturnType<typeof HeadlessTemplatedRowTable.filtering<TData, HeadlessTemplatedRowTable.FilterConfig<TData, FC>>>,
|
|
12
|
+
ReturnType<typeof HeadlessTemplatedRowTable.search<TData>>,
|
|
13
|
+
ReturnType<typeof HeadlessTemplatedRowTable.sort<TData, SC>>,
|
|
14
|
+
ReturnType<typeof HeadlessTemplatedRowTable.rowSelection<TData>>,
|
|
15
|
+
ReturnType<typeof HeadlessTemplatedRowTable.pagination<TData>>
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
export function useClientTable<
|
|
19
|
+
TData extends HeadlessTemplatedRowTable.RowData,
|
|
20
|
+
FC extends HeadlessTemplatedRowTable.FilterConfig<NoInfer<TData>> = {},
|
|
21
|
+
SC extends HeadlessTemplatedRowTable.SortConfig<NoInfer<TData>> = {}
|
|
22
|
+
>({
|
|
23
|
+
rows: rowsInput,
|
|
24
|
+
filters,
|
|
25
|
+
search,
|
|
26
|
+
sorts,
|
|
27
|
+
getRowId,
|
|
28
|
+
defaultPageSize,
|
|
29
|
+
onFiltersChange,
|
|
30
|
+
onSearchChange,
|
|
31
|
+
onSortChange,
|
|
32
|
+
onPaginationChange,
|
|
33
|
+
onRowSelectionChange,
|
|
34
|
+
manualFiltering,
|
|
35
|
+
manualSearch,
|
|
36
|
+
manualSort,
|
|
37
|
+
manualPagination
|
|
38
|
+
}: {
|
|
39
|
+
rows: TData[]
|
|
40
|
+
filters?: FC & HeadlessTemplatedRowTable.FilterConfig<NoInfer<TData>, FC>
|
|
41
|
+
search?: MatchSorterOptions<TData>
|
|
42
|
+
sorts?: SC & HeadlessTemplatedRowTable.SortConfig<NoInfer<TData>, SC>
|
|
43
|
+
getRowId: (row: TData) => string
|
|
44
|
+
defaultPageSize?: number
|
|
45
|
+
manualFiltering?: boolean
|
|
46
|
+
onFiltersChange?: (
|
|
47
|
+
val:
|
|
48
|
+
| HeadlessTemplatedRowTable.FilterState<TData, FC>
|
|
49
|
+
| ((prev: HeadlessTemplatedRowTable.FilterState<TData, FC>) => HeadlessTemplatedRowTable.FilterState<TData, FC>)
|
|
50
|
+
) => void
|
|
51
|
+
manualSearch?: boolean
|
|
52
|
+
onSearchChange?: (val: string | ((prev: string) => string)) => void
|
|
53
|
+
manualSort?: boolean
|
|
54
|
+
onSortChange?: (
|
|
55
|
+
val:
|
|
56
|
+
| HeadlessTemplatedRowTable.SortState<SC>
|
|
57
|
+
| ((prev: HeadlessTemplatedRowTable.SortState<SC>) => HeadlessTemplatedRowTable.SortState<SC>)
|
|
58
|
+
) => void
|
|
59
|
+
manualPagination?: boolean
|
|
60
|
+
onPaginationChange?: (val: any) => void
|
|
61
|
+
onRowSelectionChange?: (val: Set<string>) => void
|
|
62
|
+
}): HeadlessTemplatedRowTable.ReactTable<TData, ClientFeatures<TData, FC, SC>> {
|
|
63
|
+
const tableRef = React.useRef(null as HeadlessTemplatedRowTable.ReactTable<TData, ClientFeatures<TData, FC, SC>> | null)
|
|
64
|
+
|
|
65
|
+
const options = React.useMemo(() => {
|
|
66
|
+
const applyState = (key: string, val: any) => {
|
|
67
|
+
tableRef.current!.setState((prev: any) => ({
|
|
68
|
+
...prev,
|
|
69
|
+
[key]: typeof val === 'function' ? val(prev[key]) : val
|
|
70
|
+
}))
|
|
71
|
+
}
|
|
72
|
+
const resetDependentState = (resetPagination: boolean) => {
|
|
73
|
+
tableRef.current!.setState((prev: any) => {
|
|
74
|
+
const resetPag = resetPagination && !manualPagination && prev.pagination?.skip !== 0
|
|
75
|
+
const resetSel = prev.rowSelection?.size > 0
|
|
76
|
+
if (!resetPag && !resetSel) return prev
|
|
77
|
+
return {
|
|
78
|
+
...prev,
|
|
79
|
+
...(resetPag ? { pagination: { ...prev.pagination, skip: 0 } } : {}),
|
|
80
|
+
...(resetSel ? { rowSelection: new Set() } : {})
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
onFiltersChange: (val: any) => {
|
|
87
|
+
if (onFiltersChange) onFiltersChange(val)
|
|
88
|
+
else applyState('filters', val)
|
|
89
|
+
resetDependentState(true)
|
|
90
|
+
},
|
|
91
|
+
onSearchChange: (val: any) => {
|
|
92
|
+
if (onSearchChange) onSearchChange(val)
|
|
93
|
+
else applyState('search', val)
|
|
94
|
+
resetDependentState(true)
|
|
95
|
+
},
|
|
96
|
+
onSortChange: (val: any) => {
|
|
97
|
+
if (onSortChange) onSortChange(val)
|
|
98
|
+
else applyState('sort', val)
|
|
99
|
+
resetDependentState(true)
|
|
100
|
+
},
|
|
101
|
+
onPaginationChange: (val: any) => {
|
|
102
|
+
if (onPaginationChange) onPaginationChange(val)
|
|
103
|
+
else applyState('pagination', val)
|
|
104
|
+
resetDependentState(false)
|
|
105
|
+
},
|
|
106
|
+
onRowSelectionChange,
|
|
107
|
+
manualFiltering,
|
|
108
|
+
manualSearch,
|
|
109
|
+
manualSort,
|
|
110
|
+
manualPagination
|
|
111
|
+
}
|
|
112
|
+
}, [
|
|
113
|
+
onFiltersChange,
|
|
114
|
+
onSearchChange,
|
|
115
|
+
onSortChange,
|
|
116
|
+
onPaginationChange,
|
|
117
|
+
onRowSelectionChange,
|
|
118
|
+
manualFiltering,
|
|
119
|
+
manualSearch,
|
|
120
|
+
manualSort,
|
|
121
|
+
manualPagination
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
if (!tableRef.current) {
|
|
125
|
+
tableRef.current = HeadlessTemplatedRowTable.createTable({
|
|
126
|
+
features: [
|
|
127
|
+
HeadlessTemplatedRowTable.filtering<TData, HeadlessTemplatedRowTable.FilterConfig<TData, FC>>(
|
|
128
|
+
(filters ?? {}) as HeadlessTemplatedRowTable.FilterConfig<TData, FC>
|
|
129
|
+
),
|
|
130
|
+
HeadlessTemplatedRowTable.search<TData>(search ?? {}),
|
|
131
|
+
HeadlessTemplatedRowTable.sort<TData, SC>((sorts ?? {}) as SC),
|
|
132
|
+
HeadlessTemplatedRowTable.rowCount(),
|
|
133
|
+
HeadlessTemplatedRowTable.rowSelection<TData>(getRowId),
|
|
134
|
+
HeadlessTemplatedRowTable.pagination<TData>(defaultPageSize ?? 10)
|
|
135
|
+
],
|
|
136
|
+
rows: rowsInput,
|
|
137
|
+
...options
|
|
138
|
+
}) as HeadlessTemplatedRowTable.ReactTable<TData, ClientFeatures<TData, FC, SC>>
|
|
139
|
+
}
|
|
140
|
+
const table = tableRef.current
|
|
141
|
+
|
|
142
|
+
const state = React.useSyncExternalStore(
|
|
143
|
+
table.subscribe,
|
|
144
|
+
() => table.state,
|
|
145
|
+
() => table.state
|
|
146
|
+
)
|
|
147
|
+
table.state = state
|
|
148
|
+
|
|
149
|
+
React.useEffect(() => {
|
|
150
|
+
table.setRows(rowsInput)
|
|
151
|
+
}, [rowsInput])
|
|
152
|
+
React.useEffect(() => {
|
|
153
|
+
table.setOptions(options)
|
|
154
|
+
}, [options])
|
|
155
|
+
|
|
156
|
+
return table
|
|
157
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { useClientTable } from './use-client-table'
|
|
2
|
+
import type { TableLocalization } from './table-types'
|
|
3
|
+
import type { Iso } from 'iso-fns'
|
|
4
|
+
|
|
5
|
+
// =============================================================================
|
|
6
|
+
// Helpers
|
|
7
|
+
// =============================================================================
|
|
8
|
+
|
|
9
|
+
type Expect<T extends true> = T
|
|
10
|
+
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Setup
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
type Row = {
|
|
17
|
+
id: string
|
|
18
|
+
status: 'active' | 'inactive'
|
|
19
|
+
name: string
|
|
20
|
+
createdAt: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// Filter and sort config inference
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
function testFilterAndSortInference() {
|
|
28
|
+
const table = useClientTable({
|
|
29
|
+
rows: [] as Row[],
|
|
30
|
+
getRowId: (row) => row.id,
|
|
31
|
+
filters: {
|
|
32
|
+
status: (row, value) => row.status === value
|
|
33
|
+
},
|
|
34
|
+
sorts: {
|
|
35
|
+
newest: (a, b) => a.createdAt.localeCompare(b.createdAt),
|
|
36
|
+
oldest: (a, b) => b.createdAt.localeCompare(a.createdAt)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// getFilterConfig() keys match the provided filter names
|
|
41
|
+
type _FilterKeys = Expect<Equal<keyof ReturnType<typeof table.getFilterConfig>, 'status'>>
|
|
42
|
+
|
|
43
|
+
// getSortConfig() keys match the provided sort names
|
|
44
|
+
type _SortKeys = Expect<Equal<keyof ReturnType<typeof table.getSortConfig>, 'newest' | 'oldest'>>
|
|
45
|
+
|
|
46
|
+
// state.sort is the union of sort keys | null
|
|
47
|
+
type _SortState = Expect<Equal<typeof table.state.sort, 'newest' | 'oldest' | null>>
|
|
48
|
+
|
|
49
|
+
// state.filters keys match filter names
|
|
50
|
+
type _FilterStateKeys = Expect<Equal<keyof typeof table.state.filters, 'status'>>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// Localization compatibility — select filter
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
function testLocalizationWithSelectFilter() {
|
|
58
|
+
const table = useClientTable({
|
|
59
|
+
rows: [] as Row[],
|
|
60
|
+
getRowId: (row) => row.id,
|
|
61
|
+
filters: {
|
|
62
|
+
status: (row, value) => row.status === value
|
|
63
|
+
},
|
|
64
|
+
sorts: {
|
|
65
|
+
newest: (a, b) => 0,
|
|
66
|
+
oldest: (a, b) => 0
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
type Loc = TableLocalization<
|
|
71
|
+
ReturnType<typeof table.getFilterConfig>,
|
|
72
|
+
ReturnType<typeof table.getSortConfig>
|
|
73
|
+
>
|
|
74
|
+
|
|
75
|
+
type _LocFilterKeys = Expect<Equal<keyof Loc['filters'], 'status'>>
|
|
76
|
+
type _LocSortKeys = Expect<Equal<keyof Loc['sorts'], 'newest' | 'oldest'>>
|
|
77
|
+
|
|
78
|
+
// Valid localization with typed option values
|
|
79
|
+
const _loc: Loc = {
|
|
80
|
+
filters: {
|
|
81
|
+
status: {
|
|
82
|
+
label: 'Status',
|
|
83
|
+
options: [
|
|
84
|
+
{ label: 'Active', value: 'active' },
|
|
85
|
+
{ label: 'Inactive', value: 'inactive' }
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
sorts: {
|
|
90
|
+
newest: { label: 'Newest' },
|
|
91
|
+
oldest: { label: 'Oldest' }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// =============================================================================
|
|
97
|
+
// Localization compatibility — date range filter
|
|
98
|
+
// =============================================================================
|
|
99
|
+
|
|
100
|
+
function testLocalizationWithDateRangeFilter() {
|
|
101
|
+
type RowWithDate = {
|
|
102
|
+
id: string
|
|
103
|
+
dateRange: { startDate: Iso.Date; endDate: Iso.Date }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const table = useClientTable({
|
|
107
|
+
rows: [] as RowWithDate[],
|
|
108
|
+
getRowId: (row) => row.id,
|
|
109
|
+
filters: {
|
|
110
|
+
dateRange: (row, value) => true
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
type Loc = TableLocalization<
|
|
115
|
+
ReturnType<typeof table.getFilterConfig>,
|
|
116
|
+
ReturnType<typeof table.getSortConfig>
|
|
117
|
+
>
|
|
118
|
+
|
|
119
|
+
// Valid: date-range filter accepts DateRangeTableFilter form
|
|
120
|
+
const _loc: Loc = {
|
|
121
|
+
filters: {
|
|
122
|
+
dateRange: { label: 'Date Range', type: 'date-range' }
|
|
123
|
+
},
|
|
124
|
+
sorts: {}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Invalid: date-range filter rejects select-style options
|
|
128
|
+
const _badLoc: Loc = {
|
|
129
|
+
filters: {
|
|
130
|
+
dateRange: {
|
|
131
|
+
label: 'Date Range',
|
|
132
|
+
type: 'date-range',
|
|
133
|
+
// @ts-expect-error — select-style options array is not valid for date-range
|
|
134
|
+
options: [{ label: 'X', value: 'x' }]
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
sorts: {}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// =============================================================================
|
|
142
|
+
// Filter predicate contextual typing — row and value are well typed
|
|
143
|
+
// =============================================================================
|
|
144
|
+
|
|
145
|
+
function testFilterPredicateContextualTyping() {
|
|
146
|
+
useClientTable({
|
|
147
|
+
rows: [] as Row[],
|
|
148
|
+
getRowId: (row) => row.id,
|
|
149
|
+
filters: {
|
|
150
|
+
// @ts-expect-error — 'nonExistent' doesn't exist on Row
|
|
151
|
+
status: (row, value) => row.nonExistent === value
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
useClientTable({
|
|
156
|
+
rows: [] as Row[],
|
|
157
|
+
getRowId: (row) => row.id,
|
|
158
|
+
filters: {
|
|
159
|
+
// value is Row['status'], comparing to a number should error
|
|
160
|
+
// @ts-expect-error — Row['status'] is not comparable to number
|
|
161
|
+
status: (row, value) => value === 123
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// =============================================================================
|
|
167
|
+
// Sort comparator contextual typing — row is well typed
|
|
168
|
+
// =============================================================================
|
|
169
|
+
|
|
170
|
+
function testSortComparatorContextualTyping() {
|
|
171
|
+
useClientTable({
|
|
172
|
+
rows: [] as Row[],
|
|
173
|
+
getRowId: (row) => row.id,
|
|
174
|
+
sorts: {
|
|
175
|
+
// @ts-expect-error — 'nonExistent' doesn't exist on Row
|
|
176
|
+
byName: (a, b) => a.nonExistent.localeCompare(b.name)
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// =============================================================================
|
|
182
|
+
// Negative: wrong localization option value
|
|
183
|
+
// =============================================================================
|
|
184
|
+
|
|
185
|
+
declare function checkLocalization(l: TableLocalization<{ status: 'active' | 'inactive' }, {}>): void
|
|
186
|
+
|
|
187
|
+
function testWrongLocalizationOptionValue() {
|
|
188
|
+
checkLocalization({
|
|
189
|
+
filters: {
|
|
190
|
+
status: {
|
|
191
|
+
label: 'Status',
|
|
192
|
+
// @ts-expect-error — 'unknown' is not assignable to Row['status']
|
|
193
|
+
options: [{ label: 'Unknown', value: 'unknown' }]
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
sorts: {}
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// =============================================================================
|
|
201
|
+
// No filters/sorts → empty localization
|
|
202
|
+
// =============================================================================
|
|
203
|
+
|
|
204
|
+
function testNoFiltersOrSorts() {
|
|
205
|
+
const table = useClientTable({
|
|
206
|
+
rows: [] as Row[],
|
|
207
|
+
getRowId: (row) => row.id
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
type Loc = TableLocalization<
|
|
211
|
+
ReturnType<typeof table.getFilterConfig>,
|
|
212
|
+
ReturnType<typeof table.getSortConfig>
|
|
213
|
+
>
|
|
214
|
+
|
|
215
|
+
type _EmptyFilters = Expect<Equal<Loc['filters'], {}>>
|
|
216
|
+
type _EmptySorts = Expect<Equal<Loc['sorts'], {}>>
|
|
217
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useSearchParams as useSearchParamsRR } from 'react-router'
|
|
3
|
+
import { HeadlessTemplatedRowTable } from './headless-templated-row-table'
|
|
4
|
+
import { SearchParams } from '../../utils/use-query-state'
|
|
5
|
+
import { type ServerTable } from './server-table'
|
|
6
|
+
|
|
7
|
+
type ServerFilterConfig<TData extends HeadlessTemplatedRowTable.RowData, FC extends Record<string, any>> = {
|
|
8
|
+
[K in keyof FC & string]: HeadlessTemplatedRowTable.FilterPredicate<TData, K, NonNullable<FC[K]>>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type ServerSortConfig<TData extends HeadlessTemplatedRowTable.RowData, SortKeys extends string> = Record<
|
|
12
|
+
SortKeys,
|
|
13
|
+
HeadlessTemplatedRowTable.SortComparator<TData>
|
|
14
|
+
>
|
|
15
|
+
|
|
16
|
+
type ServerFeatures<
|
|
17
|
+
TData extends HeadlessTemplatedRowTable.RowData,
|
|
18
|
+
FC extends Record<string, any>,
|
|
19
|
+
SC extends Record<string, any>
|
|
20
|
+
> = [
|
|
21
|
+
ReturnType<typeof HeadlessTemplatedRowTable.rowCount<TData>>,
|
|
22
|
+
ReturnType<typeof HeadlessTemplatedRowTable.filtering<TData, ServerFilterConfig<TData, FC>>>,
|
|
23
|
+
ReturnType<typeof HeadlessTemplatedRowTable.sort<TData, ServerSortConfig<TData, keyof SC & string>>>,
|
|
24
|
+
ReturnType<typeof HeadlessTemplatedRowTable.search<TData>>,
|
|
25
|
+
ReturnType<typeof HeadlessTemplatedRowTable.rowSelection<TData>>,
|
|
26
|
+
ReturnType<typeof HeadlessTemplatedRowTable.pagination<TData>>
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const noopSearchCodec = SearchParams.codecs.string().default('')
|
|
30
|
+
const noopPaginationLoader = SearchParams.createLoader({
|
|
31
|
+
limit: SearchParams.codecs.int().default(0),
|
|
32
|
+
skip: SearchParams.codecs.int().default(0)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export function useServerTable<
|
|
36
|
+
TData extends HeadlessTemplatedRowTable.RowData,
|
|
37
|
+
FC extends Record<string, any> = {},
|
|
38
|
+
SC extends Record<string, any> = {}
|
|
39
|
+
>(
|
|
40
|
+
serverTable: ServerTable.ServerTable<TData, FC, SC>,
|
|
41
|
+
_options: {
|
|
42
|
+
getRowId: (row: TData) => string
|
|
43
|
+
}
|
|
44
|
+
): HeadlessTemplatedRowTable.ReactTable<TData, ServerFeatures<TData, FC, SC>> {
|
|
45
|
+
type SortKey = keyof SC & string
|
|
46
|
+
|
|
47
|
+
const filterCodec = React.useMemo(
|
|
48
|
+
() => SearchParams.deserializeLoader(serverTable.filters),
|
|
49
|
+
[serverTable.filters]
|
|
50
|
+
) as SearchParams.QueryStateLoader<FC>
|
|
51
|
+
const sortCodec = React.useMemo(() => SearchParams.deserializeCodec(serverTable.sorts), [serverTable.sorts])
|
|
52
|
+
const searchCodec = React.useMemo(
|
|
53
|
+
() => (serverTable.search ? SearchParams.deserializeCodec(serverTable.search) : noopSearchCodec),
|
|
54
|
+
[serverTable.search]
|
|
55
|
+
)
|
|
56
|
+
const paginationCodec = React.useMemo(
|
|
57
|
+
() => (serverTable.pagination ? SearchParams.deserializeLoader(serverTable.pagination) : noopPaginationLoader),
|
|
58
|
+
[serverTable.pagination]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const [filters] = SearchParams.useSearchParams(filterCodec)
|
|
62
|
+
const [sort] = SearchParams.useState('sort', sortCodec)
|
|
63
|
+
const sortValues = serverTable.sorts.values
|
|
64
|
+
const [search] = SearchParams.useState('search', searchCodec)
|
|
65
|
+
const [pagination, setPagination] = SearchParams.useSearchParams(paginationCodec)
|
|
66
|
+
const [, setURLParams] = useSearchParamsRR()
|
|
67
|
+
|
|
68
|
+
// Build noop configs for features (manual mode — predicates/comparators won't be called)
|
|
69
|
+
const filterConfig = React.useMemo(
|
|
70
|
+
() => Object.fromEntries(Object.keys(filterCodec ?? {}).map((key) => [key, () => true])),
|
|
71
|
+
[filterCodec]
|
|
72
|
+
) as unknown as ServerFilterConfig<TData, FC>
|
|
73
|
+
const sortConfig = React.useMemo(
|
|
74
|
+
() => Object.fromEntries(sortValues.map((v) => [v, () => 0])),
|
|
75
|
+
[sortValues]
|
|
76
|
+
) as unknown as ServerSortConfig<TData, SortKey>
|
|
77
|
+
|
|
78
|
+
const state = React.useMemo(
|
|
79
|
+
() => ({
|
|
80
|
+
filters,
|
|
81
|
+
sort: sort as SortKey | null,
|
|
82
|
+
search: search ?? '',
|
|
83
|
+
pagination: { limit: pagination.limit, skip: pagination.skip }
|
|
84
|
+
}),
|
|
85
|
+
[filters, sort, search, pagination]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const options = React.useMemo(
|
|
89
|
+
() => ({
|
|
90
|
+
manualFiltering: true as const,
|
|
91
|
+
manualSearch: true as const,
|
|
92
|
+
manualSort: true as const,
|
|
93
|
+
manualPagination: true as const,
|
|
94
|
+
onFiltersChange: (v: any) => {
|
|
95
|
+
setURLParams(
|
|
96
|
+
(prev) => {
|
|
97
|
+
const next = new URLSearchParams(prev)
|
|
98
|
+
const oldFilters = Object.fromEntries(
|
|
99
|
+
Object.entries(filterCodec).map(([name, codec]: [string, any]) => {
|
|
100
|
+
try {
|
|
101
|
+
return [name, codec.decode(next.get(name)) ?? codec._def?.defaultValue ?? null]
|
|
102
|
+
} catch {
|
|
103
|
+
return [name, codec._def?.defaultValue ?? null]
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
)
|
|
107
|
+
const newFilters = typeof v === 'function' ? v(oldFilters) : v
|
|
108
|
+
for (const [name, codec] of Object.entries(filterCodec) as [string, any][]) {
|
|
109
|
+
const encoded = codec.encode(newFilters[name])
|
|
110
|
+
if (
|
|
111
|
+
encoded === null ||
|
|
112
|
+
(codec._def?.defaultValue != null &&
|
|
113
|
+
codec._def?.clearOnDefault &&
|
|
114
|
+
encoded === codec.encode(codec._def?.defaultValue))
|
|
115
|
+
) {
|
|
116
|
+
next.delete(name)
|
|
117
|
+
} else {
|
|
118
|
+
next.set(name, encoded)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
next.delete('skip')
|
|
122
|
+
return next
|
|
123
|
+
},
|
|
124
|
+
{ preventScrollReset: true, replace: true }
|
|
125
|
+
)
|
|
126
|
+
table.clearRowSelection?.()
|
|
127
|
+
},
|
|
128
|
+
onSortChange: (v: any) => {
|
|
129
|
+
setURLParams(
|
|
130
|
+
(prev) => {
|
|
131
|
+
const next = new URLSearchParams(prev)
|
|
132
|
+
const newSort = typeof v === 'function' ? v(sortCodec.decode(next.get('sort'))) : v
|
|
133
|
+
const encoded = sortCodec.encode(newSort)
|
|
134
|
+
if (encoded === null) next.delete('sort')
|
|
135
|
+
else next.set('sort', encoded)
|
|
136
|
+
next.delete('skip')
|
|
137
|
+
return next
|
|
138
|
+
},
|
|
139
|
+
{ preventScrollReset: true, replace: true }
|
|
140
|
+
)
|
|
141
|
+
table.clearRowSelection?.()
|
|
142
|
+
},
|
|
143
|
+
onSearchChange: (v: any) => {
|
|
144
|
+
setURLParams(
|
|
145
|
+
(prev) => {
|
|
146
|
+
const next = new URLSearchParams(prev)
|
|
147
|
+
const newSearch = typeof v === 'function' ? v(next.get('search') ?? '') : v
|
|
148
|
+
if (newSearch) next.set('search', newSearch)
|
|
149
|
+
else next.delete('search')
|
|
150
|
+
next.delete('skip')
|
|
151
|
+
return next
|
|
152
|
+
},
|
|
153
|
+
{ preventScrollReset: true, replace: true }
|
|
154
|
+
)
|
|
155
|
+
table.clearRowSelection?.()
|
|
156
|
+
},
|
|
157
|
+
onPaginationChange: (v: any) => setPagination(v, { preventScrollReset: true, replace: true }),
|
|
158
|
+
rowCount: serverTable.totalRows
|
|
159
|
+
}),
|
|
160
|
+
[filterCodec, sortCodec, setURLParams, setPagination, serverTable.totalRows]
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const { current: table } = React.useRef(
|
|
164
|
+
HeadlessTemplatedRowTable.createTable({
|
|
165
|
+
features: [
|
|
166
|
+
HeadlessTemplatedRowTable.rowCount(),
|
|
167
|
+
HeadlessTemplatedRowTable.filtering<TData, ServerFilterConfig<TData, FC>>(filterConfig),
|
|
168
|
+
HeadlessTemplatedRowTable.sort<TData, ServerSortConfig<TData, SortKey>>(sortConfig),
|
|
169
|
+
HeadlessTemplatedRowTable.search<TData>({} as any),
|
|
170
|
+
HeadlessTemplatedRowTable.rowSelection<TData>(_options.getRowId),
|
|
171
|
+
HeadlessTemplatedRowTable.pagination<TData>(pagination.limit ?? 25)
|
|
172
|
+
],
|
|
173
|
+
rows: serverTable.rows,
|
|
174
|
+
initialState: state,
|
|
175
|
+
...options
|
|
176
|
+
})
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
table.state = React.useSyncExternalStore(
|
|
180
|
+
table.subscribe,
|
|
181
|
+
() => table.state,
|
|
182
|
+
() => table.state
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
table.setRows(serverTable.rows)
|
|
186
|
+
|
|
187
|
+
React.useEffect(() => {
|
|
188
|
+
table.setOptions({ state, ...options })
|
|
189
|
+
}, [state, options])
|
|
190
|
+
|
|
191
|
+
return table
|
|
192
|
+
}
|