@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/column-visibility.d.ts +22 -0
  3. package/dist/column-visibility.d.ts.map +1 -0
  4. package/dist/column-visibility.js +40 -0
  5. package/dist/dynamic-columns.d.ts.map +1 -1
  6. package/dist/dynamic-columns.js +4 -1
  7. package/dist/dynamic-form-schema.d.ts +5 -0
  8. package/dist/dynamic-form-schema.d.ts.map +1 -1
  9. package/dist/dynamic-form-schema.js +34 -0
  10. package/dist/dynamic-form.d.ts.map +1 -1
  11. package/dist/dynamic-form.js +18 -2
  12. package/dist/dynamic-relation.d.ts.map +1 -1
  13. package/dist/dynamic-relation.js +59 -22
  14. package/dist/dynamic-table.d.ts.map +1 -1
  15. package/dist/dynamic-table.js +17 -3
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +4 -0
  19. package/dist/types.d.ts +44 -0
  20. package/dist/types.d.ts.map +1 -1
  21. package/dist/use-options-resolver.d.ts +87 -0
  22. package/dist/use-options-resolver.d.ts.map +1 -0
  23. package/dist/use-options-resolver.js +147 -0
  24. package/dist/use-org-config-bridge.d.ts +28 -0
  25. package/dist/use-org-config-bridge.d.ts.map +1 -0
  26. package/dist/use-org-config-bridge.js +50 -0
  27. package/package.json +3 -2
  28. package/src/__tests__/column-visibility.test.ts +116 -0
  29. package/src/__tests__/use-options-resolver.test.ts +127 -0
  30. package/src/column-visibility.ts +43 -0
  31. package/src/dynamic-columns.tsx +4 -1
  32. package/src/dynamic-form-schema.ts +36 -0
  33. package/src/dynamic-form.tsx +40 -2
  34. package/src/dynamic-relation.tsx +55 -20
  35. package/src/dynamic-table.tsx +20 -2
  36. package/src/index.ts +19 -0
  37. package/src/types.ts +49 -0
  38. package/src/use-options-resolver.ts +232 -0
  39. package/src/use-org-config-bridge.ts +60 -0
  40. package/tsconfig.json +2 -1
  41. package/dist/__tests__/dynamic-form.test.d.ts +0 -2
  42. package/dist/__tests__/dynamic-form.test.d.ts.map +0 -1
  43. package/dist/__tests__/dynamic-form.test.js +0 -93
  44. package/dist/__tests__/dynamic-relation.test.d.ts +0 -2
  45. package/dist/__tests__/dynamic-relation.test.d.ts.map +0 -1
  46. 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
  }
@@ -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
- {renderField(field, values[field.key], (v: any) => update(field.key, v))}
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
- function renderField(field: ActionFieldDef, value: any, onChange: (v: any) => void) {
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
+
@@ -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
- const targetPath = referencesEndpoint || `/data/${references}`
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
- const fetchAll = useCallback(async () => {
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 [metaRes, pivotRes, targetRes] = await Promise.all([
397
- targetMeta ? Promise.resolve(null) : api.get(`/metadata/table/${references}`),
412
+ const tasks: Promise<unknown>[] = [
398
413
  api.get(pivotPath, { params }),
399
- api.get(targetPath),
400
- ])
401
- if (metaRes && (metaRes as any).data?.success) {
402
- const fresh = (metaRes as { data: ApiResponse<TableMetadata> }).data.data
403
- setTargetMeta(fresh)
404
- cacheMetadata(references, fresh)
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, targetPath, foreignKey, parentId, references, targetMeta, cacheMetadata])
440
+ }, [api, pivotPath, foreignKey, parentId, references, targetMeta, cacheMetadata, useResolver, legacyTargetPath])
416
441
 
417
- useEffect(() => { fetchAll() }, [fetchAll])
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 fetchAll()
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, fetchAll, foreignKey, onChange, parentId, pivotIndex, pivotPath, refKey, selectedIds, syncing])
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">
@@ -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) params.search = 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
+ }