@asteby/metacore-runtime-react 20.1.6 → 21.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/CHANGELOG.md +43 -0
- package/dist/dynamic-columns.d.ts +12 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +31 -20
- package/dist/dynamic-kanban.d.ts +13 -1
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +66 -29
- package/dist/dynamic-row-actions.d.ts +36 -0
- package/dist/dynamic-row-actions.d.ts.map +1 -0
- package/dist/dynamic-row-actions.js +110 -0
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +11 -76
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/permissions-context.d.ts +15 -1
- package/dist/permissions-context.d.ts.map +1 -1
- package/dist/permissions-context.js +29 -0
- package/dist/use-dynamic-filters.d.ts +38 -0
- package/dist/use-dynamic-filters.d.ts.map +1 -0
- package/dist/use-dynamic-filters.js +226 -0
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban.test.tsx +67 -6
- package/src/__tests__/resolve-row-actions.test.ts +80 -0
- package/src/dynamic-columns.tsx +33 -19
- package/src/dynamic-kanban.tsx +147 -47
- package/src/dynamic-row-actions.tsx +195 -0
- package/src/dynamic-table.tsx +12 -107
- package/src/index.ts +11 -0
- package/src/permissions-context.tsx +39 -0
- package/src/use-dynamic-filters.ts +272 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// useDynamicFilters — the metadata-driven filter engine shared by DynamicTable
|
|
2
|
+
// and DynamicKanban. It owns nothing view-specific: given a model's
|
|
3
|
+
// `TableMetadata`, it derives the per-field filter configs (from
|
|
4
|
+
// `metadata.filters[]` + every `metadata.columns[].filterable` column),
|
|
5
|
+
// prefetches the server-side option lists for relation/select filters, tracks
|
|
6
|
+
// the selected values + global search, and serializes them into the exact
|
|
7
|
+
// `f_<key>=<op>:<value>` / `search` query params the `/data/:model` endpoint
|
|
8
|
+
// already understands.
|
|
9
|
+
//
|
|
10
|
+
// DynamicTable historically inlined this; DynamicKanban now needs the SAME
|
|
11
|
+
// filtering (RFC: a board is just another view of the model), so the logic is
|
|
12
|
+
// factored here to guarantee both surfaces filter identically.
|
|
13
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
14
|
+
import { useApi } from './api-context'
|
|
15
|
+
import { DATE_CELL_TYPES } from './dynamic-columns'
|
|
16
|
+
import { getSearchableColumnKeys } from './column-visibility'
|
|
17
|
+
import type { ColumnFilterConfig, FilterOption } from './dynamic-columns-shim'
|
|
18
|
+
import type { TableMetadata } from './types'
|
|
19
|
+
|
|
20
|
+
export interface UseDynamicFiltersOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Static equality filters always applied (never shown as removable chips).
|
|
23
|
+
* Mirrors DynamicTable's `defaultFilters` — e.g. scoping a board to one owner.
|
|
24
|
+
*/
|
|
25
|
+
defaultFilters?: Record<string, any>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UseDynamicFiltersResult {
|
|
29
|
+
/** Per-field selected values, `filterKey → values[]` (empty = inactive). */
|
|
30
|
+
dynamicFilters: Record<string, string[]>
|
|
31
|
+
/** Free-text search term (the toolbar search box). */
|
|
32
|
+
globalFilter: string
|
|
33
|
+
setGlobalFilter: (v: string) => void
|
|
34
|
+
/** Config for each filterable field, keyed by the metadata filter/column key. */
|
|
35
|
+
columnFilterConfigs: Map<string, ColumnFilterConfig>
|
|
36
|
+
/**
|
|
37
|
+
* The serialized query params (`f_<key>`, `search`, `search_columns`) ready to
|
|
38
|
+
* spread into a `/data/:model` request. Stable object identity while inputs
|
|
39
|
+
* are unchanged so a consumer can safely depend on it in a fetch effect.
|
|
40
|
+
*/
|
|
41
|
+
filterParams: Record<string, any>
|
|
42
|
+
/** Apply a field's selection (replaces its values). */
|
|
43
|
+
handleDynamicFilterChange: (filterKey: string, values: string[]) => void
|
|
44
|
+
/** Count of fields with an active selection + the search term. */
|
|
45
|
+
activeFilterCount: number
|
|
46
|
+
/** Clear every field filter and the search term. */
|
|
47
|
+
clearAll: () => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reads the model's filter metadata and returns the live filter state + the
|
|
52
|
+
* serialized request params. Pure of any view concern — DynamicTable adds its
|
|
53
|
+
* own sort/pagination/`columnFilters`; DynamicKanban spreads `filterParams`
|
|
54
|
+
* straight onto its single-page fetch.
|
|
55
|
+
*/
|
|
56
|
+
export function useDynamicFilters(
|
|
57
|
+
metadata: TableMetadata | null,
|
|
58
|
+
opts: UseDynamicFiltersOptions = {},
|
|
59
|
+
): UseDynamicFiltersResult {
|
|
60
|
+
const { defaultFilters } = opts
|
|
61
|
+
const api = useApi()
|
|
62
|
+
|
|
63
|
+
const [dynamicFilters, setDynamicFilters] = useState<Record<string, string[]>>({})
|
|
64
|
+
const [globalFilter, setGlobalFilter] = useState('')
|
|
65
|
+
const [filterOptionsMap, setFilterOptionsMap] = useState<
|
|
66
|
+
Map<string, FilterOption[]>
|
|
67
|
+
>(new Map())
|
|
68
|
+
|
|
69
|
+
// Prefetch the option lists for relation/select filters (once per model). The
|
|
70
|
+
// combobox needs these before the user opens it; mirrors DynamicTable's
|
|
71
|
+
// metadata-init option prefetch, narrowed to the FILTER endpoints only (the
|
|
72
|
+
// kanban renders card cells through ActivityValueRenderer, not the cell
|
|
73
|
+
// options map, so we skip that half).
|
|
74
|
+
const prefetchedSig = useRef<string | null>(null)
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!metadata) return
|
|
77
|
+
const filterEndpoints = (metadata.filters || [])
|
|
78
|
+
.filter(
|
|
79
|
+
(f) =>
|
|
80
|
+
f.searchEndpoint &&
|
|
81
|
+
(f.type === 'select' ||
|
|
82
|
+
f.type === 'dynamic_select' ||
|
|
83
|
+
f.type === 'boolean'),
|
|
84
|
+
)
|
|
85
|
+
.map((f) => f.searchEndpoint!)
|
|
86
|
+
const columnFilterEndpoints = (metadata.columns || [])
|
|
87
|
+
.filter((c) => c.filterable && c.searchEndpoint)
|
|
88
|
+
.map((c) => c.searchEndpoint!)
|
|
89
|
+
const endpoints = Array.from(
|
|
90
|
+
new Set([...filterEndpoints, ...columnFilterEndpoints]),
|
|
91
|
+
)
|
|
92
|
+
if (endpoints.length === 0) return
|
|
93
|
+
// Dedup on the endpoint set, not model identity (TableMetadata carries no
|
|
94
|
+
// name) — the same board re-rendering shouldn't refetch its options.
|
|
95
|
+
const sig = endpoints.join('|')
|
|
96
|
+
if (prefetchedSig.current === sig) return
|
|
97
|
+
prefetchedSig.current = sig
|
|
98
|
+
|
|
99
|
+
let cancelled = false
|
|
100
|
+
Promise.all(
|
|
101
|
+
endpoints.map(async (ep) => {
|
|
102
|
+
try {
|
|
103
|
+
const res = await api.get(ep)
|
|
104
|
+
return {
|
|
105
|
+
endpoint: ep,
|
|
106
|
+
data: res.data?.success ? res.data.data : [],
|
|
107
|
+
}
|
|
108
|
+
} catch (e) {
|
|
109
|
+
console.error(`Failed to fetch filter options for ${ep}`, e)
|
|
110
|
+
return { endpoint: ep, data: [] as any[] }
|
|
111
|
+
}
|
|
112
|
+
}),
|
|
113
|
+
).then((results) => {
|
|
114
|
+
if (cancelled) return
|
|
115
|
+
const fMap = new Map<string, FilterOption[]>()
|
|
116
|
+
for (const r of results) {
|
|
117
|
+
fMap.set(
|
|
118
|
+
r.endpoint,
|
|
119
|
+
(r.data || []).map((item: any) => ({
|
|
120
|
+
label: item.label || item.name || '',
|
|
121
|
+
value: String(item.value ?? item.id ?? ''),
|
|
122
|
+
icon: item.icon,
|
|
123
|
+
color: item.color || item.class,
|
|
124
|
+
})),
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
setFilterOptionsMap(fMap)
|
|
128
|
+
})
|
|
129
|
+
return () => {
|
|
130
|
+
cancelled = true
|
|
131
|
+
}
|
|
132
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
133
|
+
}, [metadata])
|
|
134
|
+
|
|
135
|
+
const handleDynamicFilterChange = useCallback(
|
|
136
|
+
(filterKey: string, values: string[]) => {
|
|
137
|
+
setDynamicFilters((prev) => ({ ...prev, [filterKey]: values }))
|
|
138
|
+
},
|
|
139
|
+
[],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
const clearAll = useCallback(() => {
|
|
143
|
+
setDynamicFilters({})
|
|
144
|
+
setGlobalFilter('')
|
|
145
|
+
}, [])
|
|
146
|
+
|
|
147
|
+
// Derive one ColumnFilterConfig per filterable field. Explicit
|
|
148
|
+
// `metadata.filters[]` wins; otherwise every `filterable` column yields a
|
|
149
|
+
// config with its UI inferred from the column shape. Copied 1:1 from
|
|
150
|
+
// DynamicTable so table and board build identical configs.
|
|
151
|
+
const columnFilterConfigs = useMemo(() => {
|
|
152
|
+
const map = new Map<string, ColumnFilterConfig>()
|
|
153
|
+
if (!metadata) return map
|
|
154
|
+
for (const f of metadata.filters ?? []) {
|
|
155
|
+
const fType = f.type as ColumnFilterConfig['filterType']
|
|
156
|
+
let options: FilterOption[] = []
|
|
157
|
+
if (f.options && f.options.length > 0) {
|
|
158
|
+
options = f.options.map((o) => ({
|
|
159
|
+
label: o.label,
|
|
160
|
+
value: String(o.value),
|
|
161
|
+
icon: o.icon,
|
|
162
|
+
color: o.color,
|
|
163
|
+
}))
|
|
164
|
+
}
|
|
165
|
+
if (f.searchEndpoint && filterOptionsMap.has(f.searchEndpoint)) {
|
|
166
|
+
options = filterOptionsMap.get(f.searchEndpoint) || []
|
|
167
|
+
}
|
|
168
|
+
if (fType === 'select' && options.length === 0 && !f.searchEndpoint) continue
|
|
169
|
+
map.set(f.key, {
|
|
170
|
+
filterType: fType,
|
|
171
|
+
filterKey: f.column || f.key,
|
|
172
|
+
options,
|
|
173
|
+
selectedValues: dynamicFilters[f.column || f.key] || [],
|
|
174
|
+
onFilterChange: handleDynamicFilterChange,
|
|
175
|
+
loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
|
|
176
|
+
searchEndpoint: f.searchEndpoint,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
for (const c of metadata.columns ?? []) {
|
|
180
|
+
if (!c.filterable || map.has(c.key)) continue
|
|
181
|
+
const hasStaticOptions = (c.options?.length ?? 0) > 0
|
|
182
|
+
const hasEndpoint = !!c.searchEndpoint
|
|
183
|
+
const isRelation = !!c.ref || c.filterType === 'dynamic_select'
|
|
184
|
+
let filterType: ColumnFilterConfig['filterType']
|
|
185
|
+
if (c.filterType) filterType = c.filterType
|
|
186
|
+
else if (isRelation && hasEndpoint) filterType = 'dynamic_select'
|
|
187
|
+
else if (hasStaticOptions || hasEndpoint) filterType = 'select'
|
|
188
|
+
else if (c.type === 'boolean') filterType = 'boolean'
|
|
189
|
+
else if (c.type === 'number') filterType = 'number_range'
|
|
190
|
+
else if ((DATE_CELL_TYPES as readonly string[]).includes(c.type))
|
|
191
|
+
filterType = 'date_range'
|
|
192
|
+
else filterType = 'text'
|
|
193
|
+
|
|
194
|
+
const options = hasStaticOptions
|
|
195
|
+
? c.options!.map((o) => ({
|
|
196
|
+
label: o.label,
|
|
197
|
+
value: String(o.value),
|
|
198
|
+
icon: o.icon,
|
|
199
|
+
color: o.color,
|
|
200
|
+
}))
|
|
201
|
+
: hasEndpoint && filterOptionsMap.has(c.searchEndpoint!)
|
|
202
|
+
? filterOptionsMap.get(c.searchEndpoint!) || []
|
|
203
|
+
: []
|
|
204
|
+
map.set(c.key, {
|
|
205
|
+
filterType,
|
|
206
|
+
filterKey: c.key,
|
|
207
|
+
options,
|
|
208
|
+
selectedValues: dynamicFilters[c.key] || [],
|
|
209
|
+
onFilterChange: handleDynamicFilterChange,
|
|
210
|
+
loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint!),
|
|
211
|
+
searchEndpoint: c.searchEndpoint,
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
return map
|
|
215
|
+
}, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange])
|
|
216
|
+
|
|
217
|
+
const searchableKeys = useMemo(
|
|
218
|
+
() => (metadata ? getSearchableColumnKeys(metadata) : null),
|
|
219
|
+
[metadata],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
// Serialize to `/data/:model` query params — identical operator encoding to
|
|
223
|
+
// DynamicTable.buildFilterParams (IN:/RANGE:/GTE:/LTE:/ILIKE:/plain).
|
|
224
|
+
const filterParams = useMemo(() => {
|
|
225
|
+
const params: Record<string, any> = {}
|
|
226
|
+
if (globalFilter) {
|
|
227
|
+
if (searchableKeys === null) {
|
|
228
|
+
params.search = globalFilter
|
|
229
|
+
} else if (searchableKeys.length > 0) {
|
|
230
|
+
params.search = globalFilter
|
|
231
|
+
params.search_columns = searchableKeys.join(',')
|
|
232
|
+
}
|
|
233
|
+
// searchableKeys === [] → no searchable column, skip the search param.
|
|
234
|
+
}
|
|
235
|
+
if (defaultFilters) {
|
|
236
|
+
Object.entries(defaultFilters).forEach(([key, value]) => {
|
|
237
|
+
params[`f_${key}`] = value
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
Object.entries(dynamicFilters).forEach(([key, values]) => {
|
|
241
|
+
if (values.length === 0) return
|
|
242
|
+
const gteVal = values.find((v) => v.startsWith('GTE:'))
|
|
243
|
+
const lteVal = values.find((v) => v.startsWith('LTE:'))
|
|
244
|
+
if (gteVal || lteVal) {
|
|
245
|
+
const min = gteVal ? gteVal.replace('GTE:', '') : ''
|
|
246
|
+
const max = lteVal ? lteVal.replace('LTE:', '') : ''
|
|
247
|
+
params[`f_${key}`] = `RANGE:${min},${max}`
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
if (values.length === 1) params[`f_${key}`] = values[0]
|
|
251
|
+
else params[`f_${key}`] = `IN:${values.join(',')}`
|
|
252
|
+
})
|
|
253
|
+
return params
|
|
254
|
+
}, [globalFilter, searchableKeys, defaultFilters, dynamicFilters])
|
|
255
|
+
|
|
256
|
+
const activeFilterCount = useMemo(() => {
|
|
257
|
+
let n = Object.values(dynamicFilters).filter((v) => v.length > 0).length
|
|
258
|
+
if (globalFilter) n += 1
|
|
259
|
+
return n
|
|
260
|
+
}, [dynamicFilters, globalFilter])
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
dynamicFilters,
|
|
264
|
+
globalFilter,
|
|
265
|
+
setGlobalFilter,
|
|
266
|
+
columnFilterConfigs,
|
|
267
|
+
filterParams,
|
|
268
|
+
handleDynamicFilterChange,
|
|
269
|
+
activeFilterCount,
|
|
270
|
+
clearAll,
|
|
271
|
+
}
|
|
272
|
+
}
|