@asteby/metacore-runtime-react 9.0.0 → 9.2.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 +46 -0
- package/dist/column-visibility.d.ts +22 -0
- package/dist/column-visibility.d.ts.map +1 -0
- package/dist/column-visibility.js +40 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +4 -1
- package/dist/dynamic-form-schema.d.ts +5 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +34 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +18 -2
- package/dist/dynamic-relation.d.ts.map +1 -1
- package/dist/dynamic-relation.js +59 -22
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +17 -3
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-options-resolver.d.ts +87 -0
- package/dist/use-options-resolver.d.ts.map +1 -0
- package/dist/use-options-resolver.js +147 -0
- package/dist/use-org-config-bridge.d.ts +28 -0
- package/dist/use-org-config-bridge.d.ts.map +1 -0
- package/dist/use-org-config-bridge.js +50 -0
- package/package.json +3 -2
- package/src/__tests__/column-visibility.test.ts +116 -0
- package/src/__tests__/use-options-resolver.test.ts +127 -0
- package/src/column-visibility.ts +43 -0
- package/src/dynamic-columns.tsx +4 -1
- package/src/dynamic-form-schema.ts +36 -0
- package/src/dynamic-form.tsx +40 -2
- package/src/dynamic-relation.tsx +55 -20
- package/src/dynamic-table.tsx +20 -2
- package/src/index.ts +19 -0
- package/src/types.ts +49 -0
- package/src/use-options-resolver.ts +232 -0
- package/src/use-org-config-bridge.ts +60 -0
- package/tsconfig.json +2 -1
- package/dist/__tests__/dynamic-form.test.d.ts +0 -2
- package/dist/__tests__/dynamic-form.test.d.ts.map +0 -1
- package/dist/__tests__/dynamic-form.test.js +0 -93
- package/dist/__tests__/dynamic-relation.test.d.ts +0 -2
- package/dist/__tests__/dynamic-relation.test.d.ts.map +0 -1
- package/dist/__tests__/dynamic-relation.test.js +0 -228
|
@@ -3,6 +3,37 @@
|
|
|
3
3
|
// metacore-ui primitives.
|
|
4
4
|
import { z, type ZodTypeAny } from 'zod'
|
|
5
5
|
import type { ActionFieldDef, FieldValidation } from './types'
|
|
6
|
+
import { resolveValidatorToken } from './use-org-config-bridge'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Built-in validators the SDK knows how to apply by symbolic name. Apps
|
|
10
|
+
* that wire `OrgConfigProvider` map `$org.<key>` references to one of
|
|
11
|
+
* these slugs (or to a custom slug they register). Unknown slugs are a
|
|
12
|
+
* no-op so unresolved $org references degrade to "no extra check"
|
|
13
|
+
* rather than a runtime crash — matches the kernel's pass-through
|
|
14
|
+
* semantics for unresolved references.
|
|
15
|
+
*/
|
|
16
|
+
const builtinValidators: Record<string, (s: z.ZodString) => z.ZodString> = {
|
|
17
|
+
// The SDK ships ZERO fiscal vocabulary by default. Apps register
|
|
18
|
+
// their own validators (mx.rfc, co.nit, pe.ruc, etc.) via
|
|
19
|
+
// `registerValidator` so kernel/SDK stay region-agnostic.
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Apps register validator implementations by slug. The slug is the value
|
|
24
|
+
* `OrgConfig.validators[<key>]` returns for a $org.<key> reference.
|
|
25
|
+
*/
|
|
26
|
+
export function registerValidator(slug: string, fn: (s: z.ZodString) => z.ZodString): void {
|
|
27
|
+
builtinValidators[slug] = fn
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function applyCustomValidator(s: z.ZodString, customToken: string | undefined): z.ZodString {
|
|
31
|
+
if (!customToken) return s
|
|
32
|
+
const resolved = resolveValidatorToken(customToken)
|
|
33
|
+
if (!resolved) return s
|
|
34
|
+
const fn = builtinValidators[resolved]
|
|
35
|
+
return fn ? fn(s) : s
|
|
36
|
+
}
|
|
6
37
|
|
|
7
38
|
// Builds a zod object schema from an ActionFieldDef[]. Required fields stay
|
|
8
39
|
// non-empty; optional fields accept undefined / "". Validation rules
|
|
@@ -44,6 +75,11 @@ function fieldToZod(field: ActionFieldDef): ZodTypeAny {
|
|
|
44
75
|
if (field.type === 'email') s = s.email('Email inválido')
|
|
45
76
|
if (field.type === 'url') s = s.url('URL inválida')
|
|
46
77
|
|
|
78
|
+
// Custom validator: a literal slug (`mx.rfc`) OR a `$org.<key>`
|
|
79
|
+
// reference resolved through the OrgConfigProvider. Unknown slugs
|
|
80
|
+
// pass through as no-ops so apps never crash on missing config.
|
|
81
|
+
s = applyCustomValidator(s, v.custom)
|
|
82
|
+
|
|
47
83
|
if (field.required) {
|
|
48
84
|
return s.min(Math.max(typeof v.min === 'number' ? v.min : 1, 1), `${field.label} es requerido`)
|
|
49
85
|
}
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from '@asteby/metacore-ui/primitives'
|
|
17
17
|
import type { ActionFieldDef } from './types'
|
|
18
18
|
import { buildZodSchema, resolveWidget } from './dynamic-form-schema'
|
|
19
|
+
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
19
20
|
|
|
20
21
|
export { buildZodSchema, resolveWidget }
|
|
21
22
|
|
|
@@ -81,7 +82,11 @@ export function DynamicForm({
|
|
|
81
82
|
{field.label}
|
|
82
83
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
|
83
84
|
</Label>
|
|
84
|
-
|
|
85
|
+
<FieldRenderer
|
|
86
|
+
field={field}
|
|
87
|
+
value={values[field.key]}
|
|
88
|
+
onChange={(v: any) => update(field.key, v)}
|
|
89
|
+
/>
|
|
85
90
|
{errors[field.key] && (
|
|
86
91
|
<span className="text-red-500 text-sm" role="alert">{errors[field.key]}</span>
|
|
87
92
|
)}
|
|
@@ -99,8 +104,21 @@ export function DynamicForm({
|
|
|
99
104
|
)
|
|
100
105
|
}
|
|
101
106
|
|
|
102
|
-
|
|
107
|
+
interface FieldRendererProps {
|
|
108
|
+
field: ActionFieldDef
|
|
109
|
+
value: any
|
|
110
|
+
onChange: (v: any) => void
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
|
|
103
114
|
const widget = resolveWidget(field)
|
|
115
|
+
// Ref-driven select: hook into useOptionsResolver so the canonical
|
|
116
|
+
// /api/options/<ref>?field=id endpoint feeds the dropdown. This is
|
|
117
|
+
// the path the kernel auto-derives for FK columns; legacy callers
|
|
118
|
+
// shipping inline `options` keep working in the branch below.
|
|
119
|
+
if (widget === 'select' && field.ref) {
|
|
120
|
+
return <RefSelect field={field} value={value} onChange={onChange} />
|
|
121
|
+
}
|
|
104
122
|
switch (widget) {
|
|
105
123
|
case 'textarea':
|
|
106
124
|
return <Textarea id={field.key} value={value || ''} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
|
|
@@ -131,3 +149,23 @@ function renderField(field: ActionFieldDef, value: any, onChange: (v: any) => vo
|
|
|
131
149
|
}
|
|
132
150
|
}
|
|
133
151
|
|
|
152
|
+
function RefSelect({ field, value, onChange }: FieldRendererProps) {
|
|
153
|
+
const { options, loading } = useOptionsResolver({
|
|
154
|
+
modelKey: '', // unused — `ref` drives the URL
|
|
155
|
+
fieldKey: 'id',
|
|
156
|
+
ref: field.ref,
|
|
157
|
+
})
|
|
158
|
+
return (
|
|
159
|
+
<Select value={value || ''} onValueChange={onChange} disabled={loading}>
|
|
160
|
+
<SelectTrigger>
|
|
161
|
+
<SelectValue placeholder={loading ? 'Cargando…' : (field.placeholder || 'Seleccionar...')} />
|
|
162
|
+
</SelectTrigger>
|
|
163
|
+
<SelectContent>
|
|
164
|
+
{options.map((opt: ResolvedOption) => (
|
|
165
|
+
<SelectItem key={String(opt.id)} value={String(opt.id)}>{opt.label}</SelectItem>
|
|
166
|
+
))}
|
|
167
|
+
</SelectContent>
|
|
168
|
+
</Select>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
package/src/dynamic-relation.tsx
CHANGED
|
@@ -25,6 +25,7 @@ import { Plus, Trash2, Pencil } from 'lucide-react'
|
|
|
25
25
|
import { useApi } from './api-context'
|
|
26
26
|
import { useMetadataCache } from './metadata-cache'
|
|
27
27
|
import { DynamicForm } from './dynamic-form'
|
|
28
|
+
import { useOptionsResolver } from './use-options-resolver'
|
|
28
29
|
import type { ApiResponse, TableMetadata } from './types'
|
|
29
30
|
import {
|
|
30
31
|
buildCreatePayload,
|
|
@@ -380,7 +381,13 @@ function ManyToManyRelation({
|
|
|
380
381
|
|
|
381
382
|
const refKey = referencesKey || `${references}_id`
|
|
382
383
|
const pivotPath = pivotEndpoint || `/data/${through}`
|
|
383
|
-
|
|
384
|
+
// referencesEndpoint is preserved as a legacy escape hatch — when set
|
|
385
|
+
// we keep the old `/data/<references>` raw fetch path (so apps that
|
|
386
|
+
// depend on a custom server route do not break). When unset we use
|
|
387
|
+
// the canonical `/api/options/:references` endpoint via
|
|
388
|
+
// useOptionsResolver, which is what the kernel auto-derives Ref to.
|
|
389
|
+
const useResolver = !referencesEndpoint
|
|
390
|
+
const legacyTargetPath = referencesEndpoint || `/data/${references}`
|
|
384
391
|
|
|
385
392
|
const cachedTargetMeta = getMetadata(references)
|
|
386
393
|
const [targetMeta, setTargetMeta] = useState<TableMetadata | null>(cachedTargetMeta || null)
|
|
@@ -389,41 +396,65 @@ function ManyToManyRelation({
|
|
|
389
396
|
const [loading, setLoading] = useState(true)
|
|
390
397
|
const [syncing, setSyncing] = useState(false)
|
|
391
398
|
|
|
392
|
-
|
|
399
|
+
// Canonical path: SDK options resolver. Only fires when no legacy
|
|
400
|
+
// override is set. The hook is a no-op when `useResolver` is false.
|
|
401
|
+
const resolved = useOptionsResolver({
|
|
402
|
+
modelKey: '',
|
|
403
|
+
fieldKey: 'id',
|
|
404
|
+
ref: useResolver ? references : undefined,
|
|
405
|
+
enabled: useResolver,
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
const fetchPivotAndMeta = useCallback(async () => {
|
|
393
409
|
setLoading(true)
|
|
394
410
|
try {
|
|
395
411
|
const params = buildRelationFilterParams(foreignKey, parentId)
|
|
396
|
-
const [
|
|
397
|
-
targetMeta ? Promise.resolve(null) : api.get(`/metadata/table/${references}`),
|
|
412
|
+
const tasks: Promise<unknown>[] = [
|
|
398
413
|
api.get(pivotPath, { params }),
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
414
|
+
]
|
|
415
|
+
if (!targetMeta) tasks.push(api.get(`/metadata/table/${references}`))
|
|
416
|
+
// Legacy fallback path: the resolver is disabled, fetch the
|
|
417
|
+
// target rows the old way so callers that depend on a custom
|
|
418
|
+
// route keep working.
|
|
419
|
+
if (!useResolver) tasks.push(api.get(legacyTargetPath))
|
|
420
|
+
const results = await Promise.all(tasks)
|
|
421
|
+
const pivotRes = results[0] as { data: ApiResponse<any[]> }
|
|
422
|
+
if (pivotRes.data.success) setPivotRows(pivotRes.data.data || [])
|
|
423
|
+
let cursor = 1
|
|
424
|
+
if (!targetMeta) {
|
|
425
|
+
const metaRes = results[cursor++] as { data: ApiResponse<TableMetadata> }
|
|
426
|
+
if (metaRes.data?.success) {
|
|
427
|
+
setTargetMeta(metaRes.data.data)
|
|
428
|
+
cacheMetadata(references, metaRes.data.data)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (!useResolver) {
|
|
432
|
+
const targetRes = results[cursor++] as { data: ApiResponse<any[]> }
|
|
433
|
+
if (targetRes.data.success) setTargetRows(targetRes.data.data || [])
|
|
405
434
|
}
|
|
406
|
-
const pivotList = (pivotRes as { data: ApiResponse<any[]> }).data
|
|
407
|
-
if (pivotList.success) setPivotRows(pivotList.data || [])
|
|
408
|
-
const targetList = (targetRes as { data: ApiResponse<any[]> }).data
|
|
409
|
-
if (targetList.success) setTargetRows(targetList.data || [])
|
|
410
435
|
} catch (err) {
|
|
411
436
|
console.error('DynamicRelation m2m fetch error', err)
|
|
412
437
|
} finally {
|
|
413
438
|
setLoading(false)
|
|
414
439
|
}
|
|
415
|
-
}, [api, pivotPath,
|
|
440
|
+
}, [api, pivotPath, foreignKey, parentId, references, targetMeta, cacheMetadata, useResolver, legacyTargetPath])
|
|
416
441
|
|
|
417
|
-
useEffect(() => {
|
|
442
|
+
useEffect(() => { fetchPivotAndMeta() }, [fetchPivotAndMeta])
|
|
418
443
|
|
|
419
444
|
const options = useMemo(() => {
|
|
445
|
+
if (useResolver) {
|
|
446
|
+
return resolved.options.map((o) => ({
|
|
447
|
+
value: String(o.id),
|
|
448
|
+
label: o.label,
|
|
449
|
+
}))
|
|
450
|
+
}
|
|
420
451
|
return targetRows
|
|
421
452
|
.filter(r => r && r.id !== undefined && r.id !== null && r.id !== '')
|
|
422
453
|
.map(r => ({
|
|
423
454
|
value: String(r.id),
|
|
424
455
|
label: pickOptionLabel(r, displayKey, targetMeta?.columns),
|
|
425
456
|
}))
|
|
426
|
-
}, [targetRows, displayKey, targetMeta])
|
|
457
|
+
}, [useResolver, resolved.options, targetRows, displayKey, targetMeta])
|
|
427
458
|
|
|
428
459
|
const selectedIds = useMemo(
|
|
429
460
|
() => extractSelectedTargetIds(pivotRows, refKey),
|
|
@@ -454,14 +485,18 @@ function ManyToManyRelation({
|
|
|
454
485
|
const res = await api.delete(`${pivotPath}/${pivotId}`)
|
|
455
486
|
if (!(res as any).data?.success) throw new Error('detach failed')
|
|
456
487
|
}
|
|
457
|
-
await
|
|
488
|
+
await fetchPivotAndMeta()
|
|
489
|
+
// Refresh resolver-driven options when active so newly attached
|
|
490
|
+
// targets reflect immediately. Refetching the pivot rows alone
|
|
491
|
+
// is enough when the resolver branch is off.
|
|
492
|
+
if (useResolver) resolved.refetch()
|
|
458
493
|
onChange?.()
|
|
459
494
|
} catch (err) {
|
|
460
495
|
console.error('DynamicRelation m2m sync error', err)
|
|
461
496
|
} finally {
|
|
462
497
|
setSyncing(false)
|
|
463
498
|
}
|
|
464
|
-
}, [api, canCreate, canDelete,
|
|
499
|
+
}, [api, canCreate, canDelete, fetchPivotAndMeta, useResolver, resolved, foreignKey, onChange, parentId, pivotIndex, pivotPath, refKey, selectedIds, syncing])
|
|
465
500
|
|
|
466
501
|
return (
|
|
467
502
|
<div
|
|
@@ -476,7 +511,7 @@ function ManyToManyRelation({
|
|
|
476
511
|
</div>
|
|
477
512
|
)}
|
|
478
513
|
|
|
479
|
-
{loading ? (
|
|
514
|
+
{(loading || (useResolver && resolved.loading)) ? (
|
|
480
515
|
<Skeleton className="h-10 w-full" />
|
|
481
516
|
) : options.length === 0 ? (
|
|
482
517
|
<div className="text-center text-sm text-muted-foreground py-8 border rounded-md bg-muted/30">
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -69,6 +69,7 @@ import { defaultGetDynamicColumns } from './dynamic-columns'
|
|
|
69
69
|
import { OptionsContext } from './options-context'
|
|
70
70
|
import { ActionModalDispatcher } from './action-modal-dispatcher'
|
|
71
71
|
import type { TableMetadata, ApiResponse, ActionMetadata } from './types'
|
|
72
|
+
import { getSearchableColumnKeys } from './column-visibility'
|
|
72
73
|
import { DynamicRecordDialog } from './dialogs/dynamic-record'
|
|
73
74
|
import { ExportDialog } from './dialogs/export'
|
|
74
75
|
import { ImportDialog } from './dialogs/import'
|
|
@@ -324,13 +325,30 @@ export function DynamicTable({
|
|
|
324
325
|
initMetadataAndOptions()
|
|
325
326
|
}, [model]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
326
327
|
|
|
328
|
+
// Derived from `metadata.columns[].searchable`. `null` means the kernel
|
|
329
|
+
// didn't emit the flag for any column → preserve legacy "search every
|
|
330
|
+
// column" behaviour by not narrowing the request. An empty array means
|
|
331
|
+
// every column was explicitly opted out → skip sending `search` at all.
|
|
332
|
+
const searchableKeys = useMemo(
|
|
333
|
+
() => (metadata ? getSearchableColumnKeys(metadata) : null),
|
|
334
|
+
[metadata],
|
|
335
|
+
)
|
|
336
|
+
|
|
327
337
|
const buildFilterParams = useCallback(() => {
|
|
328
338
|
const params: Record<string, any> = {}
|
|
329
339
|
if (sorting.length > 0) {
|
|
330
340
|
params.sortBy = sorting[0].id
|
|
331
341
|
params.order = sorting[0].desc ? 'desc' : 'asc'
|
|
332
342
|
}
|
|
333
|
-
if (globalFilter)
|
|
343
|
+
if (globalFilter) {
|
|
344
|
+
if (searchableKeys === null) {
|
|
345
|
+
params.search = globalFilter
|
|
346
|
+
} else if (searchableKeys.length > 0) {
|
|
347
|
+
params.search = globalFilter
|
|
348
|
+
params.search_columns = searchableKeys.join(',')
|
|
349
|
+
}
|
|
350
|
+
// searchableKeys === [] → drop the search request entirely
|
|
351
|
+
}
|
|
334
352
|
columnFilters.forEach((filter: { id: string; value: unknown }) => { params[`f_${filter.id}`] = filter.value })
|
|
335
353
|
if (defaultFilters) Object.entries(defaultFilters).forEach(([key, value]) => { params[`f_${key}`] = value })
|
|
336
354
|
Object.entries(dynamicFilters).forEach(([key, values]) => {
|
|
@@ -352,7 +370,7 @@ export function DynamicTable({
|
|
|
352
370
|
params['f_created_at'] = `${startDate}_${endDate}`
|
|
353
371
|
}
|
|
354
372
|
return params
|
|
355
|
-
}, [sorting, globalFilter, columnFilters, defaultFilters, dynamicFilters, dateRange])
|
|
373
|
+
}, [sorting, globalFilter, columnFilters, defaultFilters, dynamicFilters, dateRange, searchableKeys])
|
|
356
374
|
|
|
357
375
|
const hasActiveFilters = useMemo(() => {
|
|
358
376
|
if (globalFilter) return true
|
package/src/index.ts
CHANGED
|
@@ -56,3 +56,22 @@ export {
|
|
|
56
56
|
type ModelExtension,
|
|
57
57
|
type ModelExtensionProps,
|
|
58
58
|
} from './model-extension-registry'
|
|
59
|
+
export {
|
|
60
|
+
isColumnVisibleInTable,
|
|
61
|
+
getSearchableColumnKeys,
|
|
62
|
+
} from './column-visibility'
|
|
63
|
+
export {
|
|
64
|
+
useOptionsResolver,
|
|
65
|
+
projectOption,
|
|
66
|
+
type ResolvedOption,
|
|
67
|
+
type OptionsMeta,
|
|
68
|
+
type UseOptionsResolverArgs,
|
|
69
|
+
type UseOptionsResolverResult,
|
|
70
|
+
} from './use-options-resolver'
|
|
71
|
+
export {
|
|
72
|
+
setOrgConfigBridge,
|
|
73
|
+
getOrgConfigBridge,
|
|
74
|
+
resolveValidatorToken,
|
|
75
|
+
type OrgConfigBridge,
|
|
76
|
+
} from './use-org-config-bridge'
|
|
77
|
+
export { registerValidator } from './dynamic-form-schema'
|
package/src/types.ts
CHANGED
|
@@ -26,6 +26,18 @@ export interface FilterDefinition {
|
|
|
26
26
|
searchEndpoint?: string
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Where a column is rendered. Mirrors `manifest.ColumnDef.Visibility` in the
|
|
31
|
+
* kernel:
|
|
32
|
+
* - `''` / `'all'` — visible everywhere (default).
|
|
33
|
+
* - `'table'` — only the list/index page.
|
|
34
|
+
* - `'modal'` — only the create/edit modal.
|
|
35
|
+
* - `'list'` — only API list payloads (omitted from UI).
|
|
36
|
+
* Hosts may extend the union with their own scopes; the SDK only acts on the
|
|
37
|
+
* canonical values above.
|
|
38
|
+
*/
|
|
39
|
+
export type ColumnVisibility = 'all' | 'table' | 'modal' | 'list' | (string & {})
|
|
40
|
+
|
|
29
41
|
export interface ColumnDefinition {
|
|
30
42
|
key: string
|
|
31
43
|
label: string
|
|
@@ -33,6 +45,19 @@ export interface ColumnDefinition {
|
|
|
33
45
|
sortable: boolean
|
|
34
46
|
filterable: boolean
|
|
35
47
|
hidden?: boolean
|
|
48
|
+
/**
|
|
49
|
+
* Scopes where this column is rendered. When `'modal'` (or `'list'`) the
|
|
50
|
+
* column is hidden from the table even if `hidden` is unset. Empty/`'all'`/
|
|
51
|
+
* `'table'` keep the column visible. See `column-visibility.ts`.
|
|
52
|
+
*/
|
|
53
|
+
visibility?: ColumnVisibility
|
|
54
|
+
/**
|
|
55
|
+
* Opts the column into the model's full-text/contains search. Independent
|
|
56
|
+
* of `filterable` (which drives column-level filter chips). When at least
|
|
57
|
+
* one column declares `searchable`, the SDK narrows the global search to
|
|
58
|
+
* those columns; otherwise legacy "search every column" behaviour applies.
|
|
59
|
+
*/
|
|
60
|
+
searchable?: boolean
|
|
36
61
|
styleConfig?: Record<string, any>
|
|
37
62
|
tooltip?: string
|
|
38
63
|
description?: string
|
|
@@ -45,6 +70,20 @@ export interface ColumnDefinition {
|
|
|
45
70
|
relationPath?: string
|
|
46
71
|
useOptions?: boolean
|
|
47
72
|
options?: { value: string; label: string; icon?: string; color?: string }[]
|
|
73
|
+
/**
|
|
74
|
+
* FK target model. When the kernel auto-derives this from a
|
|
75
|
+
* belongs_to relation (or an author sets it explicitly), the SDK
|
|
76
|
+
* resolves the column's options against `/api/options/<ref>?field=id`
|
|
77
|
+
* via `useOptionsResolver`. Wins over `searchEndpoint` for select
|
|
78
|
+
* widgets — `searchEndpoint` stays as the legacy escape hatch.
|
|
79
|
+
*/
|
|
80
|
+
ref?: string
|
|
81
|
+
/**
|
|
82
|
+
* Server-side validation rules the SDK can also pre-flight in the
|
|
83
|
+
* form layer. `custom` may be a literal slug or a $org.<key>
|
|
84
|
+
* reference resolved through the OrgConfigProvider.
|
|
85
|
+
*/
|
|
86
|
+
validation?: FieldValidation
|
|
48
87
|
}
|
|
49
88
|
|
|
50
89
|
export interface ActionCondition {
|
|
@@ -56,6 +95,10 @@ export interface ActionCondition {
|
|
|
56
95
|
// Mirrors `ValidationRule` from packages/sdk/src/generated/manifest.ts. Kept
|
|
57
96
|
// inline here so runtime-react does not import generated kernel types directly
|
|
58
97
|
// — apps and addons author ActionFieldDef literals.
|
|
98
|
+
//
|
|
99
|
+
// `custom` accepts either a literal validator slug (e.g. `mx.rfc`) registered
|
|
100
|
+
// via `registerValidator`, or a `$org.<key>` reference resolved through the
|
|
101
|
+
// OrgConfigProvider — same contract as kernel ColumnDef.Validation.Custom.
|
|
59
102
|
export interface FieldValidation {
|
|
60
103
|
regex?: string
|
|
61
104
|
min?: number
|
|
@@ -86,6 +129,12 @@ export interface ActionFieldDef {
|
|
|
86
129
|
searchEndpoint?: string
|
|
87
130
|
validation?: FieldValidation
|
|
88
131
|
widget?: FieldWidget | string
|
|
132
|
+
/**
|
|
133
|
+
* FK target model — same semantics as ColumnDefinition.ref. When
|
|
134
|
+
* present, DynamicForm resolves the field's options through
|
|
135
|
+
* `useOptionsResolver` against `/api/options/<ref>?field=id`.
|
|
136
|
+
*/
|
|
137
|
+
ref?: string
|
|
89
138
|
}
|
|
90
139
|
|
|
91
140
|
export interface ActionDefinition {
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// useOptionsResolver — single hook the SDK uses to fetch select options
|
|
2
|
+
// for a metadata-driven field. Replaces the ad-hoc `/data/<model>` reads
|
|
3
|
+
// that DynamicForm and DynamicRelation used to do.
|
|
4
|
+
//
|
|
5
|
+
// Contract (matches kernel ≥ v0.9.0):
|
|
6
|
+
// GET /api/options/:model?field=<key>&q=<text>&limit=<n>
|
|
7
|
+
// → { success: true, data: Option[], meta: { type: 'static'|'dynamic', count } }
|
|
8
|
+
//
|
|
9
|
+
// The hook prefers `ColumnDef.Ref` (auto-derived by the kernel from
|
|
10
|
+
// belongs_to relations) over a hand-wired `searchEndpoint`. Apps that
|
|
11
|
+
// adopt Ref via the kernel auto-derivation get the right behaviour for
|
|
12
|
+
// free; legacy callers that still ship `searchEndpoint` keep working.
|
|
13
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
14
|
+
import { useApi } from './api-context'
|
|
15
|
+
|
|
16
|
+
export interface ResolvedOption {
|
|
17
|
+
/** Canonical id (server-side primary key). */
|
|
18
|
+
id: string | number
|
|
19
|
+
/** Same as `id` — preserved for legacy frontend parity. */
|
|
20
|
+
value: string | number
|
|
21
|
+
/** Display string. */
|
|
22
|
+
label: string
|
|
23
|
+
/** Same as `label` — preserved for legacy frontend parity. */
|
|
24
|
+
name: string
|
|
25
|
+
description?: string | null
|
|
26
|
+
image?: string | null
|
|
27
|
+
color?: string | null
|
|
28
|
+
icon?: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface OptionsMeta {
|
|
32
|
+
/** 'static' for inline options, 'dynamic' for FK-resolved lists. */
|
|
33
|
+
type: 'static' | 'dynamic' | string
|
|
34
|
+
/** Number of options the server returned in this batch. */
|
|
35
|
+
count: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface UseOptionsResolverArgs {
|
|
39
|
+
/**
|
|
40
|
+
* The owning model whose options endpoint is queried. Pass the model
|
|
41
|
+
* key (e.g. 'sales_orders'). Required — passing an empty string puts
|
|
42
|
+
* the hook in idle mode and no fetch fires.
|
|
43
|
+
*/
|
|
44
|
+
modelKey: string
|
|
45
|
+
/**
|
|
46
|
+
* Field on `modelKey` to resolve. Maps to `?field=<fieldKey>`.
|
|
47
|
+
*/
|
|
48
|
+
fieldKey: string
|
|
49
|
+
/**
|
|
50
|
+
* Optional FK target. When set the hook resolves against
|
|
51
|
+
* `/api/options/<ref>?field=id` instead of `/api/options/<modelKey>`.
|
|
52
|
+
* This is the canonical path the kernel auto-derives from
|
|
53
|
+
* `ColumnDef.Ref`. Prefer this over `endpoint`.
|
|
54
|
+
*/
|
|
55
|
+
ref?: string
|
|
56
|
+
/**
|
|
57
|
+
* Free-text query forwarded as `?q=`. Empty values are skipped so the
|
|
58
|
+
* server returns the first page unfiltered.
|
|
59
|
+
*/
|
|
60
|
+
query?: string
|
|
61
|
+
/**
|
|
62
|
+
* Server-side pagination cap. Defaults to 50 (kernel
|
|
63
|
+
* DefaultOptionsLimit) if omitted.
|
|
64
|
+
*/
|
|
65
|
+
limit?: number
|
|
66
|
+
/**
|
|
67
|
+
* Toggle to disable fetching entirely (e.g. while a parent row is
|
|
68
|
+
* still loading). Defaults to true.
|
|
69
|
+
*/
|
|
70
|
+
enabled?: boolean
|
|
71
|
+
/**
|
|
72
|
+
* Escape hatch for callers that need a non-canonical URL — e.g.
|
|
73
|
+
* legacy `/options/<custom>?...`. When set it overrides `ref` and
|
|
74
|
+
* `modelKey` for the fetch path. The query string is built from
|
|
75
|
+
* `fieldKey` / `query` / `limit` exactly the same way.
|
|
76
|
+
*/
|
|
77
|
+
endpoint?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface UseOptionsResolverResult {
|
|
81
|
+
options: ResolvedOption[]
|
|
82
|
+
meta: OptionsMeta | null
|
|
83
|
+
loading: boolean
|
|
84
|
+
error: Error | null
|
|
85
|
+
/** Forces a refetch. Useful after a parent record updates. */
|
|
86
|
+
refetch: () => void
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolves select options for a field via the canonical
|
|
91
|
+
* `/api/options/:model?field=…` endpoint. Returns the v0.9.0 envelope
|
|
92
|
+
* `{ data, meta: { type, count } }` projected into a stable shape.
|
|
93
|
+
*
|
|
94
|
+
* The hook is intentionally minimal: it does NOT debounce `query`
|
|
95
|
+
* (callers should hold the controlled value and pass it post-debounce)
|
|
96
|
+
* and does NOT cache across hook instances (apps that need shared state
|
|
97
|
+
* compose this with TanStack Query in their own layer).
|
|
98
|
+
*/
|
|
99
|
+
export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsResolverResult {
|
|
100
|
+
const {
|
|
101
|
+
modelKey,
|
|
102
|
+
fieldKey,
|
|
103
|
+
ref,
|
|
104
|
+
query,
|
|
105
|
+
limit,
|
|
106
|
+
enabled = true,
|
|
107
|
+
endpoint,
|
|
108
|
+
} = args
|
|
109
|
+
|
|
110
|
+
const api = useApi()
|
|
111
|
+
const [options, setOptions] = useState<ResolvedOption[]>([])
|
|
112
|
+
const [meta, setMeta] = useState<OptionsMeta | null>(null)
|
|
113
|
+
const [loading, setLoading] = useState(false)
|
|
114
|
+
const [error, setError] = useState<Error | null>(null)
|
|
115
|
+
// refreshKey is bumped by `refetch` to force the effect to re-run
|
|
116
|
+
// even when none of the input args changed.
|
|
117
|
+
const [refreshKey, setRefreshKey] = useState(0)
|
|
118
|
+
|
|
119
|
+
// The URL the hook hits. Ref wins over modelKey because the kernel's
|
|
120
|
+
// auto-derivation makes ref the canonical pointer; a manual endpoint
|
|
121
|
+
// wins over both as the explicit override.
|
|
122
|
+
const url = useMemo(() => {
|
|
123
|
+
if (endpoint) return endpoint
|
|
124
|
+
if (ref) return `/options/${ref}`
|
|
125
|
+
if (!modelKey) return ''
|
|
126
|
+
return `/options/${modelKey}`
|
|
127
|
+
}, [endpoint, ref, modelKey])
|
|
128
|
+
|
|
129
|
+
// The field to query. When using `ref` the canonical lookup field is
|
|
130
|
+
// `id` (FK targets the target model's PK), unless the caller wants
|
|
131
|
+
// to override that explicitly via `fieldKey`. We only inject the `id`
|
|
132
|
+
// default when `ref` is set AND `fieldKey` is empty.
|
|
133
|
+
const effectiveField = useMemo(() => {
|
|
134
|
+
if (fieldKey) return fieldKey
|
|
135
|
+
if (ref) return 'id'
|
|
136
|
+
return ''
|
|
137
|
+
}, [fieldKey, ref])
|
|
138
|
+
|
|
139
|
+
// Track the in-flight controller so a new fetch can abort the
|
|
140
|
+
// previous one — matters for typeahead callers passing changing `query`.
|
|
141
|
+
const abortRef = useRef<AbortController | null>(null)
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (!enabled || !url || !effectiveField) {
|
|
145
|
+
setOptions([])
|
|
146
|
+
setMeta(null)
|
|
147
|
+
setLoading(false)
|
|
148
|
+
setError(null)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
// Cancel any pending request before issuing a new one.
|
|
152
|
+
abortRef.current?.abort()
|
|
153
|
+
const controller = new AbortController()
|
|
154
|
+
abortRef.current = controller
|
|
155
|
+
|
|
156
|
+
setLoading(true)
|
|
157
|
+
setError(null)
|
|
158
|
+
|
|
159
|
+
const params: Record<string, string | number> = { field: effectiveField }
|
|
160
|
+
if (query) params.q = query
|
|
161
|
+
if (typeof limit === 'number' && limit > 0) params.limit = limit
|
|
162
|
+
|
|
163
|
+
api.get(url, { params, signal: controller.signal })
|
|
164
|
+
.then((res) => {
|
|
165
|
+
if (controller.signal.aborted) return
|
|
166
|
+
const body = (res as { data: any }).data
|
|
167
|
+
if (!body || body.success !== true) {
|
|
168
|
+
throw new Error(body?.message || 'options resolver: unsuccessful response')
|
|
169
|
+
}
|
|
170
|
+
const rawOptions: any[] = Array.isArray(body.data) ? body.data : []
|
|
171
|
+
const projected = rawOptions.map(projectOption)
|
|
172
|
+
setOptions(projected)
|
|
173
|
+
// v0.9.0 envelope: meta.type / meta.count. We tolerate
|
|
174
|
+
// older deployments that still emit a root-level `type`
|
|
175
|
+
// by reading either spot — the projection prefers the
|
|
176
|
+
// canonical location so the SDK guides apps to the new
|
|
177
|
+
// shape without breaking grace-period upgrades.
|
|
178
|
+
const metaPayload =
|
|
179
|
+
body.meta && typeof body.meta === 'object'
|
|
180
|
+
? body.meta
|
|
181
|
+
: { type: body.type, count: rawOptions.length }
|
|
182
|
+
setMeta({
|
|
183
|
+
type: metaPayload?.type ?? 'dynamic',
|
|
184
|
+
count:
|
|
185
|
+
typeof metaPayload?.count === 'number'
|
|
186
|
+
? metaPayload.count
|
|
187
|
+
: rawOptions.length,
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
.catch((err: any) => {
|
|
191
|
+
if (controller.signal.aborted) return
|
|
192
|
+
setError(err instanceof Error ? err : new Error(String(err)))
|
|
193
|
+
setOptions([])
|
|
194
|
+
setMeta(null)
|
|
195
|
+
})
|
|
196
|
+
.finally(() => {
|
|
197
|
+
if (!controller.signal.aborted) setLoading(false)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
return () => {
|
|
201
|
+
controller.abort()
|
|
202
|
+
}
|
|
203
|
+
}, [api, url, effectiveField, query, limit, enabled, refreshKey])
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
options,
|
|
207
|
+
meta,
|
|
208
|
+
loading,
|
|
209
|
+
error,
|
|
210
|
+
refetch: () => setRefreshKey((k) => k + 1),
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Normalizes the wire shape into ResolvedOption. The kernel returns dual
|
|
216
|
+
* id/value and label/name fields for legacy parity — we accept either
|
|
217
|
+
* and surface a stable shape downstream.
|
|
218
|
+
*/
|
|
219
|
+
export function projectOption(raw: any): ResolvedOption {
|
|
220
|
+
const id = raw?.id ?? raw?.value ?? ''
|
|
221
|
+
const label = String(raw?.label ?? raw?.name ?? id ?? '')
|
|
222
|
+
return {
|
|
223
|
+
id,
|
|
224
|
+
value: raw?.value ?? id,
|
|
225
|
+
label,
|
|
226
|
+
name: String(raw?.name ?? label),
|
|
227
|
+
description: raw?.description ?? null,
|
|
228
|
+
image: raw?.image ?? null,
|
|
229
|
+
color: raw?.color ?? null,
|
|
230
|
+
icon: raw?.icon ?? null,
|
|
231
|
+
}
|
|
232
|
+
}
|