@asteby/metacore-runtime-react 9.1.0 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +175 -0
- package/dist/addon-layout-context.d.ts +49 -0
- package/dist/addon-layout-context.d.ts.map +1 -0
- package/dist/addon-layout-context.js +94 -0
- package/dist/addon-loader.d.ts +14 -2
- package/dist/addon-loader.d.ts.map +1 -1
- package/dist/addon-loader.js +7 -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/hotswap-reload-policy.d.ts +155 -0
- package/dist/hotswap-reload-policy.d.ts.map +1 -0
- package/dist/hotswap-reload-policy.js +227 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/manifest-hotswap-subscriber.d.ts +83 -0
- package/dist/manifest-hotswap-subscriber.d.ts.map +1 -0
- package/dist/manifest-hotswap-subscriber.js +104 -0
- package/dist/metadata-cache.d.ts +35 -0
- package/dist/metadata-cache.d.ts.map +1 -1
- package/dist/metadata-cache.js +55 -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 +4 -4
- package/src/__tests__/hotswap-reload-policy.test.ts +249 -0
- package/src/__tests__/manifest-hotswap-subscriber.test.ts +179 -0
- package/src/__tests__/use-options-resolver.test.ts +127 -0
- package/src/__tests__/wasm-client-sri.test.ts +82 -0
- package/src/addon-layout-context.tsx +137 -0
- package/src/addon-loader.tsx +21 -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/hotswap-reload-policy.ts +360 -0
- package/src/index.ts +43 -0
- package/src/manifest-hotswap-subscriber.ts +164 -0
- package/src/metadata-cache.ts +86 -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,82 @@
|
|
|
1
|
+
// Wasm-client SRI smoke tests — lives in runtime-react because the SDK
|
|
2
|
+
// package does not (yet) carry a vitest setup of its own. We import the
|
|
3
|
+
// helper through the workspace specifier so the test exercises the same
|
|
4
|
+
// public boundary downstream apps see.
|
|
5
|
+
//
|
|
6
|
+
// The SRI logic is decoupled from WebAssembly instantiation by design, so
|
|
7
|
+
// these tests run in plain Node without needing a real `.wasm` module.
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import {
|
|
11
|
+
verifyIntegrity,
|
|
12
|
+
WasmIntegrityError,
|
|
13
|
+
} from '@asteby/metacore-sdk'
|
|
14
|
+
|
|
15
|
+
function bytes(input: string): Uint8Array {
|
|
16
|
+
return new TextEncoder().encode(input)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function digestB64(algo: 'SHA-256' | 'SHA-384' | 'SHA-512', input: string) {
|
|
20
|
+
const buf = await crypto.subtle.digest(algo, bytes(input))
|
|
21
|
+
const view = new Uint8Array(buf)
|
|
22
|
+
let bin = ''
|
|
23
|
+
for (let i = 0; i < view.byteLength; i++) bin += String.fromCharCode(view[i]!)
|
|
24
|
+
return btoa(bin)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('verifyIntegrity', () => {
|
|
28
|
+
it('accepts a matching sha384 digest', async () => {
|
|
29
|
+
const payload = 'hello, metacore wasm world'
|
|
30
|
+
const hash = await digestB64('SHA-384', payload)
|
|
31
|
+
await expect(
|
|
32
|
+
verifyIntegrity(bytes(payload), `sha384-${hash}`),
|
|
33
|
+
).resolves.toBeUndefined()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('accepts a matching sha256 digest', async () => {
|
|
37
|
+
const payload = '{"hello":"world"}'
|
|
38
|
+
const hash = await digestB64('SHA-256', payload)
|
|
39
|
+
await expect(
|
|
40
|
+
verifyIntegrity(bytes(payload), `sha256-${hash}`),
|
|
41
|
+
).resolves.toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('throws WasmIntegrityError on digest mismatch', async () => {
|
|
45
|
+
const payload = 'hello'
|
|
46
|
+
const wrong = await digestB64('SHA-384', 'tampered')
|
|
47
|
+
await expect(
|
|
48
|
+
verifyIntegrity(bytes(payload), `sha384-${wrong}`),
|
|
49
|
+
).rejects.toBeInstanceOf(WasmIntegrityError)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('passes when ANY space-separated token matches', async () => {
|
|
53
|
+
const payload = 'multi-hash'
|
|
54
|
+
const bad = await digestB64('SHA-384', 'other')
|
|
55
|
+
const good = await digestB64('SHA-256', payload)
|
|
56
|
+
await expect(
|
|
57
|
+
verifyIntegrity(bytes(payload), `sha384-${bad} sha256-${good}`),
|
|
58
|
+
).resolves.toBeUndefined()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('is a no-op when integrity is the empty string', async () => {
|
|
62
|
+
await expect(verifyIntegrity(bytes('anything'), '')).resolves.toBeUndefined()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('rejects malformed integrity tokens', async () => {
|
|
66
|
+
await expect(
|
|
67
|
+
verifyIntegrity(bytes('x'), 'this-has-no-algo'),
|
|
68
|
+
).rejects.toBeInstanceOf(WasmIntegrityError)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('rejects unsupported algorithms', async () => {
|
|
72
|
+
await expect(
|
|
73
|
+
verifyIntegrity(bytes('x'), 'md5-deadbeef'),
|
|
74
|
+
).rejects.toBeInstanceOf(WasmIntegrityError)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('handles length-mismatched digests as a clean mismatch', async () => {
|
|
78
|
+
await expect(
|
|
79
|
+
verifyIntegrity(bytes('x'), 'sha256-short'),
|
|
80
|
+
).rejects.toBeInstanceOf(WasmIntegrityError)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// AddonLayoutContext — broadcast the active addon entry's layout selection
|
|
2
|
+
// (`shell` vs `immersive`) up to the host so it can hide/show its chrome
|
|
3
|
+
// (Sidebar, Topbar, breadcrumbs) when an immersive addon is mounted.
|
|
4
|
+
//
|
|
5
|
+
// Why a context rather than a prop on the host shell:
|
|
6
|
+
//
|
|
7
|
+
// 1. The host shell is rendered ABOVE the addon route in the tree, but the
|
|
8
|
+
// decision about what layout the addon wants comes from the addon itself
|
|
9
|
+
// (manifest.frontend.layout) which the AddonLoader knows about at mount
|
|
10
|
+
// time. A bottom-up signal via context inverts the dependency cleanly.
|
|
11
|
+
//
|
|
12
|
+
// 2. Addon entries can swap layouts at runtime (think a kiosk-mode toggle
|
|
13
|
+
// inside a POS). A context value reactively updates the host without
|
|
14
|
+
// asking each route to wire props.
|
|
15
|
+
//
|
|
16
|
+
// 3. When the user navigates AWAY from an immersive addon, the AddonLoader
|
|
17
|
+
// unmounts, its layout context updater fires `setLayout("shell")` from
|
|
18
|
+
// a cleanup effect, and the chrome restores automatically.
|
|
19
|
+
//
|
|
20
|
+
// Host integration (starter-core, ops, …):
|
|
21
|
+
//
|
|
22
|
+
// function AppShell({ children }) {
|
|
23
|
+
// const layout = useAddonLayout()
|
|
24
|
+
// const chrome = layout !== "immersive"
|
|
25
|
+
// return (
|
|
26
|
+
// <div className={chrome ? "grid grid-cols-[280px_1fr]" : "h-dvh w-dvw"}>
|
|
27
|
+
// {chrome && <Sidebar />}
|
|
28
|
+
// <main>{chrome && <Topbar />}{children}</main>
|
|
29
|
+
// </div>
|
|
30
|
+
// )
|
|
31
|
+
// }
|
|
32
|
+
//
|
|
33
|
+
// The context defaults to `"shell"`, so apps that never mount an
|
|
34
|
+
// `<AddonLayoutProvider>` keep the legacy behaviour.
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
createContext,
|
|
38
|
+
useCallback,
|
|
39
|
+
useContext,
|
|
40
|
+
useEffect,
|
|
41
|
+
useMemo,
|
|
42
|
+
useState,
|
|
43
|
+
} from 'react'
|
|
44
|
+
import type { AddonLayout } from '@asteby/metacore-sdk'
|
|
45
|
+
|
|
46
|
+
export type { AddonLayout }
|
|
47
|
+
|
|
48
|
+
interface AddonLayoutState {
|
|
49
|
+
/** Active layout. `"shell"` (default) or `"immersive"`. */
|
|
50
|
+
layout: AddonLayout
|
|
51
|
+
/**
|
|
52
|
+
* Imperative setter for the host or an addon-loader to mutate the active
|
|
53
|
+
* layout. Exposed for advanced use; most callers should use
|
|
54
|
+
* `useDeclareAddonLayout(layout)` from a route component, which scopes the
|
|
55
|
+
* change to the route's mount lifetime.
|
|
56
|
+
*/
|
|
57
|
+
setLayout: (layout: AddonLayout) => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const defaultState: AddonLayoutState = {
|
|
61
|
+
layout: 'shell',
|
|
62
|
+
setLayout: () => {
|
|
63
|
+
/* noop — provider missing; consumers degrade to legacy "shell" */
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const AddonLayoutContext = createContext<AddonLayoutState>(defaultState)
|
|
68
|
+
|
|
69
|
+
export interface AddonLayoutProviderProps {
|
|
70
|
+
/** Initial layout — usually `"shell"`. */
|
|
71
|
+
initial?: AddonLayout
|
|
72
|
+
children: React.ReactNode
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Wrap the host app once, above the router outlet. The provider keeps the
|
|
77
|
+
* currently-active layout in state; addon-loader and `useDeclareAddonLayout`
|
|
78
|
+
* mutate it from below.
|
|
79
|
+
*/
|
|
80
|
+
export function AddonLayoutProvider({
|
|
81
|
+
initial = 'shell',
|
|
82
|
+
children,
|
|
83
|
+
}: AddonLayoutProviderProps) {
|
|
84
|
+
const [layout, setLayout] = useState<AddonLayout>(initial)
|
|
85
|
+
const value = useMemo<AddonLayoutState>(
|
|
86
|
+
() => ({ layout, setLayout }),
|
|
87
|
+
[layout],
|
|
88
|
+
)
|
|
89
|
+
return (
|
|
90
|
+
<AddonLayoutContext.Provider value={value}>
|
|
91
|
+
{children}
|
|
92
|
+
</AddonLayoutContext.Provider>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Read the currently-active layout. The host shell calls this and decides
|
|
98
|
+
* whether to render its chrome. Returns `"shell"` when no provider is
|
|
99
|
+
* mounted, so apps that have not adopted immersive addons keep working.
|
|
100
|
+
*/
|
|
101
|
+
export function useAddonLayout(): AddonLayout {
|
|
102
|
+
return useContext(AddonLayoutContext).layout
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Imperative API — the value returned mirrors `useAddonLayout()` but also
|
|
107
|
+
* exposes the setter for hosts that need to flip the layout outside of a
|
|
108
|
+
* route lifecycle (e.g. a hotkey forcing kiosk mode). Most addon entries do
|
|
109
|
+
* NOT need this; prefer `useDeclareAddonLayout`.
|
|
110
|
+
*/
|
|
111
|
+
export function useAddonLayoutControl(): AddonLayoutState {
|
|
112
|
+
return useContext(AddonLayoutContext)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Declare the layout from the addon side. Mounts the value, restores
|
|
117
|
+
* `"shell"` on unmount. Skip when `layout` is undefined so route components
|
|
118
|
+
* can pass `manifest.frontend?.layout` directly without branching.
|
|
119
|
+
*
|
|
120
|
+
* function PosEntry({ manifest }: { manifest: Manifest }) {
|
|
121
|
+
* useDeclareAddonLayout(manifest.frontend?.layout)
|
|
122
|
+
* return <PosScreen />
|
|
123
|
+
* }
|
|
124
|
+
*/
|
|
125
|
+
export function useDeclareAddonLayout(layout: AddonLayout | undefined): void {
|
|
126
|
+
const { setLayout } = useAddonLayoutControl()
|
|
127
|
+
// useCallback so the effect only re-runs on a real layout change, not on
|
|
128
|
+
// every render of the consumer that happens to forward an inline literal.
|
|
129
|
+
const apply = useCallback(setLayout, [setLayout])
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!layout || layout === 'shell') return
|
|
132
|
+
apply(layout)
|
|
133
|
+
return () => {
|
|
134
|
+
apply('shell')
|
|
135
|
+
}
|
|
136
|
+
}, [layout, apply])
|
|
137
|
+
}
|
package/src/addon-loader.tsx
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// waits for the `window[scope]` container to initialize, then calls the
|
|
3
3
|
// addon's `register(api)` export with the AddonAPI injected by the host.
|
|
4
4
|
import { useEffect, useRef, useState } from 'react'
|
|
5
|
-
import type { AddonAPI } from '@asteby/metacore-sdk'
|
|
5
|
+
import type { AddonAPI, AddonLayout } from '@asteby/metacore-sdk'
|
|
6
|
+
import { useDeclareAddonLayout } from './addon-layout-context'
|
|
6
7
|
|
|
7
8
|
declare global {
|
|
8
9
|
interface Window {
|
|
@@ -27,6 +28,18 @@ export interface AddonLoaderProps {
|
|
|
27
28
|
onReady?: () => void
|
|
28
29
|
/** Called if loading fails. */
|
|
29
30
|
onError?: (err: Error) => void
|
|
31
|
+
/**
|
|
32
|
+
* Layout the host shell should render the addon under, mirroring
|
|
33
|
+
* `manifest.frontend.layout`. Default (undefined / `"shell"`) keeps the
|
|
34
|
+
* legacy chrome (Sidebar, Topbar, breadcrumbs). `"immersive"` flips the
|
|
35
|
+
* shared {@link useAddonLayout} context so the host shell hides chrome
|
|
36
|
+
* while the addon is mounted and restores it on unmount.
|
|
37
|
+
*
|
|
38
|
+
* Hosts that consume the context (see `useAddonLayout` /
|
|
39
|
+
* `<AddonLayoutProvider>`) do NOT need to branch on this prop themselves
|
|
40
|
+
* — the loader sets the context value via {@link useDeclareAddonLayout}.
|
|
41
|
+
*/
|
|
42
|
+
layout?: AddonLayout
|
|
30
43
|
children?: React.ReactNode
|
|
31
44
|
}
|
|
32
45
|
|
|
@@ -75,12 +88,19 @@ export function AddonLoader({
|
|
|
75
88
|
fallback = null,
|
|
76
89
|
onReady,
|
|
77
90
|
onError,
|
|
91
|
+
layout,
|
|
78
92
|
children,
|
|
79
93
|
}: AddonLoaderProps) {
|
|
80
94
|
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
|
81
95
|
const [error, setError] = useState<Error | null>(null)
|
|
82
96
|
const didRegister = useRef(false)
|
|
83
97
|
|
|
98
|
+
// Propagate the addon's preferred layout to the host shell via context.
|
|
99
|
+
// No-op when `layout` is undefined or `"shell"` (legacy default). Cleanup
|
|
100
|
+
// restores `"shell"` automatically when the loader unmounts, so chrome
|
|
101
|
+
// returns as soon as the user navigates away from an immersive addon.
|
|
102
|
+
useDeclareAddonLayout(layout)
|
|
103
|
+
|
|
84
104
|
useEffect(() => {
|
|
85
105
|
let cancelled = false
|
|
86
106
|
;(async () => {
|
|
@@ -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">
|