@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.
- package/CHANGELOG.md +36 -0
- 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/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/types.d.ts +20 -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 +2 -2
- package/src/__tests__/use-options-resolver.test.ts +127 -0
- package/src/dynamic-form-schema.ts +36 -0
- package/src/dynamic-form.tsx +40 -2
- package/src/dynamic-relation.tsx +55 -20
- package/src/index.ts +15 -0
- package/src/types.ts +24 -0
- package/src/use-options-resolver.ts +232 -0
- package/src/use-org-config-bridge.ts +60 -0
|
@@ -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.
|
|
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": "^
|
|
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
|
}
|
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/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 {
|