@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,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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
|
|
15
|
+
export interface OrgConfigBridge {
|
|
16
|
+
/** Resolves a `$org.<key>` reference (or plain key) to a literal id. */
|
|
17
|
+
resolveValidator: (refOrKey: string) => string | null
|
|
18
|
+
/** When true the app actually has a provider mounted. */
|
|
19
|
+
available: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const NULL_BRIDGE: OrgConfigBridge = {
|
|
23
|
+
resolveValidator: () => null,
|
|
24
|
+
available: false,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let activeBridge: OrgConfigBridge = NULL_BRIDGE
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Apps that consume `runtime-react` AND `@asteby/metacore-app-providers`
|
|
31
|
+
* call this once near the root (typically inside the OrgConfigProvider
|
|
32
|
+
* children) so the SDK reads the same resolver. Hosts without an org
|
|
33
|
+
* provider can ignore this entirely; the SDK's null bridge keeps every
|
|
34
|
+
* call returning `null` so $org.<key> tokens stay verbatim in the form
|
|
35
|
+
* — same fallback the kernel uses for unresolved references.
|
|
36
|
+
*/
|
|
37
|
+
export function setOrgConfigBridge(bridge: OrgConfigBridge | null) {
|
|
38
|
+
activeBridge = bridge ?? NULL_BRIDGE
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns the active bridge. Pure read — no React hook so it can be
|
|
43
|
+
* called from non-component code (zod schema builders, helpers).
|
|
44
|
+
*/
|
|
45
|
+
export function getOrgConfigBridge(): OrgConfigBridge {
|
|
46
|
+
return activeBridge
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolves a Validation token into the validator identifier the SDK
|
|
51
|
+
* should apply. Returns the resolved literal when the org config knows
|
|
52
|
+
* the key, or the original token when it doesn't (so apps can decide).
|
|
53
|
+
* Plain literals (no `$org.` prefix) pass through.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveValidatorToken(token: string | undefined | null): string | null {
|
|
56
|
+
if (!token) return null
|
|
57
|
+
if (!token.startsWith('$org.')) return token
|
|
58
|
+
const resolved = activeBridge.resolveValidator(token)
|
|
59
|
+
return resolved ?? token
|
|
60
|
+
}
|