@asteby/metacore-runtime-react 9.1.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.
@@ -0,0 +1,28 @@
1
+ export interface OrgConfigBridge {
2
+ /** Resolves a `$org.<key>` reference (or plain key) to a literal id. */
3
+ resolveValidator: (refOrKey: string) => string | null;
4
+ /** When true the app actually has a provider mounted. */
5
+ available: boolean;
6
+ }
7
+ /**
8
+ * Apps that consume `runtime-react` AND `@asteby/metacore-app-providers`
9
+ * call this once near the root (typically inside the OrgConfigProvider
10
+ * children) so the SDK reads the same resolver. Hosts without an org
11
+ * provider can ignore this entirely; the SDK's null bridge keeps every
12
+ * call returning `null` so $org.<key> tokens stay verbatim in the form
13
+ * — same fallback the kernel uses for unresolved references.
14
+ */
15
+ export declare function setOrgConfigBridge(bridge: OrgConfigBridge | null): void;
16
+ /**
17
+ * Returns the active bridge. Pure read — no React hook so it can be
18
+ * called from non-component code (zod schema builders, helpers).
19
+ */
20
+ export declare function getOrgConfigBridge(): OrgConfigBridge;
21
+ /**
22
+ * Resolves a Validation token into the validator identifier the SDK
23
+ * should apply. Returns the resolved literal when the org config knows
24
+ * the key, or the original token when it doesn't (so apps can decide).
25
+ * Plain literals (no `$org.` prefix) pass through.
26
+ */
27
+ export declare function resolveValidatorToken(token: string | undefined | null): string | null;
28
+ //# sourceMappingURL=use-org-config-bridge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-org-config-bridge.d.ts","sourceRoot":"","sources":["../src/use-org-config-bridge.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,eAAe;IAC5B,wEAAwE;IACxE,gBAAgB,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAA;IACrD,yDAAyD;IACzD,SAAS,EAAE,OAAO,CAAA;CACrB;AASD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI,QAEhE;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,eAAe,CAEpD;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAKrF"}
@@ -0,0 +1,50 @@
1
+ // Bridge to `useOrgConfig` from `@asteby/metacore-app-providers` without
2
+ // adding it as a hard dependency of `runtime-react`. The provider package
3
+ // is a peer; in apps that mount it the hook returns the live config, in
4
+ // apps that don't the SDK falls through to a no-op shim that resolves
5
+ // every reference to null. Forms then leave $org.<key> tokens in place
6
+ // rather than crashing — the operator notices the missing config when
7
+ // the validator fails to fire, not at app boot.
8
+ //
9
+ // Why a bridge: runtime-react cannot import `@asteby/metacore-app-providers`
10
+ // directly without inverting the dependency graph (app-providers depends
11
+ // on runtime-react today via peerDependenciesMeta). The shim shape
12
+ // matches `OrgConfigContextValue` so DynamicForm code reads through one
13
+ // stable interface regardless of provider mount.
14
+ const NULL_BRIDGE = {
15
+ resolveValidator: () => null,
16
+ available: false,
17
+ };
18
+ let activeBridge = NULL_BRIDGE;
19
+ /**
20
+ * Apps that consume `runtime-react` AND `@asteby/metacore-app-providers`
21
+ * call this once near the root (typically inside the OrgConfigProvider
22
+ * children) so the SDK reads the same resolver. Hosts without an org
23
+ * provider can ignore this entirely; the SDK's null bridge keeps every
24
+ * call returning `null` so $org.<key> tokens stay verbatim in the form
25
+ * — same fallback the kernel uses for unresolved references.
26
+ */
27
+ export function setOrgConfigBridge(bridge) {
28
+ activeBridge = bridge ?? NULL_BRIDGE;
29
+ }
30
+ /**
31
+ * Returns the active bridge. Pure read — no React hook so it can be
32
+ * called from non-component code (zod schema builders, helpers).
33
+ */
34
+ export function getOrgConfigBridge() {
35
+ return activeBridge;
36
+ }
37
+ /**
38
+ * Resolves a Validation token into the validator identifier the SDK
39
+ * should apply. Returns the resolved literal when the org config knows
40
+ * the key, or the original token when it doesn't (so apps can decide).
41
+ * Plain literals (no `$org.` prefix) pass through.
42
+ */
43
+ export function resolveValidatorToken(token) {
44
+ if (!token)
45
+ return null;
46
+ if (!token.startsWith('$org.'))
47
+ return token;
48
+ const resolved = activeBridge.resolveValidator(token);
49
+ return resolved ?? token;
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "9.1.0",
3
+ "version": "9.2.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -57,7 +57,7 @@
57
57
  "react-i18next": "^17.0.0",
58
58
  "sonner": "^2.0.0",
59
59
  "tsx": "^4.21.0",
60
- "typescript": "^5.6.0",
60
+ "typescript": "^6.0.0",
61
61
  "vitest": "^4.0.0",
62
62
  "zustand": "^5.0.0",
63
63
  "@asteby/metacore-sdk": "2.4.0",
@@ -0,0 +1,127 @@
1
+ import { afterEach, describe, it, expect, vi } from 'vitest'
2
+ import { projectOption } from '../use-options-resolver'
3
+ import {
4
+ resolveValidatorToken,
5
+ setOrgConfigBridge,
6
+ } from '../use-org-config-bridge'
7
+
8
+ // `useOptionsResolver` itself is a React hook and would need jsdom +
9
+ // react-test-renderer to exercise end-to-end. The bridge tests here
10
+ // pin down the projection layer (the only impure shape conversion the
11
+ // hook performs) so consumers can rely on the v0.9.0 envelope reading
12
+ // without spinning up a renderer.
13
+
14
+ describe('projectOption', () => {
15
+ it('mirrors id into value and label into name when missing', () => {
16
+ const out = projectOption({ id: 'abc', label: 'Hello' })
17
+ expect(out.id).toBe('abc')
18
+ expect(out.value).toBe('abc')
19
+ expect(out.label).toBe('Hello')
20
+ expect(out.name).toBe('Hello')
21
+ })
22
+
23
+ it('preserves explicit value and name when provided', () => {
24
+ const out = projectOption({ id: 1, value: 'one', label: 'L', name: 'N' })
25
+ expect(out.value).toBe('one')
26
+ expect(out.name).toBe('N')
27
+ })
28
+
29
+ it('coerces label to string from numeric id when none provided', () => {
30
+ const out = projectOption({ id: 42 })
31
+ expect(out.label).toBe('42')
32
+ expect(out.name).toBe('42')
33
+ })
34
+
35
+ it('preserves optional decoration fields', () => {
36
+ const out = projectOption({
37
+ id: 'x', label: 'X',
38
+ description: 'desc', image: '/a.png',
39
+ color: '#fff', icon: 'IconStar',
40
+ })
41
+ expect(out.description).toBe('desc')
42
+ expect(out.image).toBe('/a.png')
43
+ expect(out.color).toBe('#fff')
44
+ expect(out.icon).toBe('IconStar')
45
+ })
46
+
47
+ it('null-safes missing optionals to null', () => {
48
+ const out = projectOption({ id: 'x', label: 'X' })
49
+ expect(out.description).toBeNull()
50
+ expect(out.image).toBeNull()
51
+ expect(out.color).toBeNull()
52
+ expect(out.icon).toBeNull()
53
+ })
54
+
55
+ it('survives empty payload (defensive)', () => {
56
+ const out = projectOption({})
57
+ expect(out.id).toBe('')
58
+ expect(out.value).toBe('')
59
+ expect(out.label).toBe('')
60
+ expect(out.name).toBe('')
61
+ })
62
+ })
63
+
64
+ // The envelope shape the hook expects from the kernel is exercised here
65
+ // with a mocked transport so apps can document the wire contract in a
66
+ // single place. `useOptionsResolver` reads `body.data` for options and
67
+ // `body.meta.{type, count}` for the discriminator — the legacy
68
+ // root-level `body.type` is also accepted for grace-period upgrades.
69
+ describe('options envelope contract', () => {
70
+ it('v0.9.0 shape carries meta.type and meta.count', () => {
71
+ const wire = {
72
+ success: true,
73
+ data: [
74
+ { id: '1', label: 'One' },
75
+ { id: '2', label: 'Two' },
76
+ ],
77
+ meta: { type: 'dynamic', count: 2 },
78
+ }
79
+ // Smoke-check the projection a real call would do.
80
+ expect(wire.data.map(projectOption)).toHaveLength(2)
81
+ expect(wire.meta.type).toBe('dynamic')
82
+ expect(wire.meta.count).toBe(2)
83
+ })
84
+
85
+ it('legacy shape is identifiable but consumers should migrate', () => {
86
+ const legacy = {
87
+ success: true,
88
+ data: [{ id: '1', label: 'One' }],
89
+ // root-level type, not under meta — the SDK reads it as a
90
+ // fallback but logs no warning (kernel ≥ v0.9.0 emits the
91
+ // canonical shape; older deployments are an interop case).
92
+ type: 'static',
93
+ } as any
94
+ expect(legacy.type).toBe('static')
95
+ expect(legacy.meta).toBeUndefined()
96
+ })
97
+ })
98
+
99
+ // Sanity-check the resolver bridge: when no provider is mounted
100
+ // `resolveValidatorToken` returns the original token. Apps that mount
101
+ // `OrgConfigProvider` swap that for the resolved literal.
102
+ describe('OrgConfigBridge integration', () => {
103
+ afterEach(() => {
104
+ // Reset to the null bridge so independent tests do not leak state.
105
+ setOrgConfigBridge(null)
106
+ })
107
+
108
+ it('resolveValidatorToken passes through plain literals', () => {
109
+ expect(resolveValidatorToken('mx.rfc')).toBe('mx.rfc')
110
+ expect(resolveValidatorToken(null)).toBeNull()
111
+ expect(resolveValidatorToken('')).toBeNull()
112
+ })
113
+
114
+ it('resolveValidatorToken returns the $org reference verbatim when no bridge mounted', () => {
115
+ // Default null bridge: ref keys resolve to null → token preserved.
116
+ expect(resolveValidatorToken('$org.tax_id')).toBe('$org.tax_id')
117
+ })
118
+
119
+ it('setOrgConfigBridge swaps the active resolver and survives clearing', () => {
120
+ const spy = vi.fn((key: string) => (key === '$org.tax_id' ? 'mx.rfc' : null))
121
+ setOrgConfigBridge({ resolveValidator: spy, available: true })
122
+ expect(resolveValidatorToken('$org.tax_id')).toBe('mx.rfc')
123
+ expect(spy).toHaveBeenCalledWith('$org.tax_id')
124
+ setOrgConfigBridge(null)
125
+ expect(resolveValidatorToken('$org.tax_id')).toBe('$org.tax_id')
126
+ })
127
+ })
@@ -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">
package/src/index.ts CHANGED
@@ -60,3 +60,18 @@ export {
60
60
  isColumnVisibleInTable,
61
61
  getSearchableColumnKeys,
62
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
@@ -70,6 +70,20 @@ export interface ColumnDefinition {
70
70
  relationPath?: string
71
71
  useOptions?: boolean
72
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
73
87
  }
74
88
 
75
89
  export interface ActionCondition {
@@ -81,6 +95,10 @@ export interface ActionCondition {
81
95
  // Mirrors `ValidationRule` from packages/sdk/src/generated/manifest.ts. Kept
82
96
  // inline here so runtime-react does not import generated kernel types directly
83
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.
84
102
  export interface FieldValidation {
85
103
  regex?: string
86
104
  min?: number
@@ -111,6 +129,12 @@ export interface ActionFieldDef {
111
129
  searchEndpoint?: string
112
130
  validation?: FieldValidation
113
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
114
138
  }
115
139
 
116
140
  export interface ActionDefinition {