@actuate-media/cms-admin 0.11.0 → 0.12.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/LICENSE +21 -21
- package/dist/__tests__/fields/component-block-helpers.test.d.ts +7 -0
- package/dist/__tests__/fields/component-block-helpers.test.d.ts.map +1 -0
- package/dist/__tests__/fields/component-block-helpers.test.js +592 -0
- package/dist/__tests__/fields/component-block-helpers.test.js.map +1 -0
- package/dist/fields/ComponentBlockField.d.ts +25 -0
- package/dist/fields/ComponentBlockField.d.ts.map +1 -0
- package/dist/fields/ComponentBlockField.js +74 -0
- package/dist/fields/ComponentBlockField.js.map +1 -0
- package/dist/fields/FieldRenderer.d.ts +3 -0
- package/dist/fields/FieldRenderer.d.ts.map +1 -1
- package/dist/fields/FieldRenderer.js +3 -1
- package/dist/fields/FieldRenderer.js.map +1 -1
- package/dist/fields/PropInput.d.ts +14 -0
- package/dist/fields/PropInput.d.ts.map +1 -0
- package/dist/fields/PropInput.js +163 -0
- package/dist/fields/PropInput.js.map +1 -0
- package/dist/fields/component-block-helpers.d.ts +96 -0
- package/dist/fields/component-block-helpers.d.ts.map +1 -0
- package/dist/fields/component-block-helpers.js +323 -0
- package/dist/fields/component-block-helpers.js.map +1 -0
- package/dist/fields/index.d.ts +4 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +2 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +10 -3
- package/src/__tests__/fields/component-block-helpers.test.ts +674 -0
- package/src/fields/ComponentBlockField.tsx +179 -0
- package/src/fields/FieldRenderer.tsx +8 -0
- package/src/fields/PropInput.tsx +552 -0
- package/src/fields/component-block-helpers.ts +341 -0
- package/src/fields/index.ts +4 -0
- package/src/index.ts +7 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recursive input dispatcher for component-block props. Given a
|
|
5
|
+
* {@link PropSpec} and the currently stored value, renders the correct
|
|
6
|
+
* editor:
|
|
7
|
+
*
|
|
8
|
+
* | PropType.kind | Editor |
|
|
9
|
+
* | --------------------- | ---------------------------------------------- |
|
|
10
|
+
* | `string` | single-line text input |
|
|
11
|
+
* | `number` | native number input |
|
|
12
|
+
* | `boolean` | switch (ToggleField look) |
|
|
13
|
+
* | `enum` | select with the literal values |
|
|
14
|
+
* | `literal` | readonly display (it's the only allowed value) |
|
|
15
|
+
* | `array` | add/remove list of recursive PropInputs |
|
|
16
|
+
* | `object` | bordered group of recursive PropInputs |
|
|
17
|
+
* | `union` (discriminated) | variant picker + recursive object form |
|
|
18
|
+
* | `union` (other) | JSON textarea + hint |
|
|
19
|
+
* | `reference` | JSON textarea + "unresolved" hint |
|
|
20
|
+
* | `unknown` | JSON textarea + warning |
|
|
21
|
+
*
|
|
22
|
+
* The component is intentionally small and self-contained — no calls
|
|
23
|
+
* into cms-core; the parent {@link ComponentBlockField} runs validation
|
|
24
|
+
* via `validateComponentBlockValue` and pipes per-prop errors back in
|
|
25
|
+
* via the `errors` prop.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
detectDiscriminator,
|
|
30
|
+
findVariant,
|
|
31
|
+
} from '@actuate-media/component-blocks/discriminated-union'
|
|
32
|
+
import type { DiscriminatedUnion } from '@actuate-media/component-blocks/discriminated-union'
|
|
33
|
+
import type { PropSpec, PropType } from '@actuate-media/component-blocks'
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
defaultForType,
|
|
37
|
+
parseEnumSelection,
|
|
38
|
+
safeJsonStringify,
|
|
39
|
+
switchUnionVariant,
|
|
40
|
+
} from './component-block-helpers.js'
|
|
41
|
+
|
|
42
|
+
export { defaultForType }
|
|
43
|
+
|
|
44
|
+
export interface PropInputProps {
|
|
45
|
+
prop: PropSpec
|
|
46
|
+
value: unknown
|
|
47
|
+
onChange: (value: unknown) => void
|
|
48
|
+
/** Map keyed by `propPath` (dot-joined) for nested error display. */
|
|
49
|
+
errors?: Record<string, string>
|
|
50
|
+
/** Dot-separated path for the field; used as the error map key. */
|
|
51
|
+
path?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function PropInput({ prop, value, onChange, errors, path }: PropInputProps) {
|
|
55
|
+
const fullPath = path ?? prop.name
|
|
56
|
+
const safeErrors = errors ?? EMPTY_ERRORS
|
|
57
|
+
const error = safeErrors[fullPath]
|
|
58
|
+
return (
|
|
59
|
+
<div className="space-y-1">
|
|
60
|
+
<PropInputBody
|
|
61
|
+
prop={prop}
|
|
62
|
+
value={value}
|
|
63
|
+
onChange={onChange}
|
|
64
|
+
errors={safeErrors}
|
|
65
|
+
path={fullPath}
|
|
66
|
+
/>
|
|
67
|
+
{prop.description && !error ? (
|
|
68
|
+
<p className="text-xs text-[var(--muted-foreground)]">{prop.description}</p>
|
|
69
|
+
) : null}
|
|
70
|
+
{error ? <p className="text-xs text-[var(--destructive)]">{error}</p> : null}
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const EMPTY_ERRORS: Record<string, string> = Object.freeze({}) as Record<string, string>
|
|
76
|
+
|
|
77
|
+
interface PropInputBodyProps {
|
|
78
|
+
prop: PropSpec
|
|
79
|
+
value: unknown
|
|
80
|
+
onChange: (value: unknown) => void
|
|
81
|
+
errors: Record<string, string>
|
|
82
|
+
path: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function PropInputBody({ prop, value, onChange, errors, path }: PropInputBodyProps) {
|
|
86
|
+
const type = prop.type
|
|
87
|
+
switch (type.kind) {
|
|
88
|
+
case 'string':
|
|
89
|
+
return <StringInput label={fieldLabel(prop)} value={value} onChange={onChange} />
|
|
90
|
+
case 'number':
|
|
91
|
+
return <NumberInputControl label={fieldLabel(prop)} value={value} onChange={onChange} />
|
|
92
|
+
case 'boolean':
|
|
93
|
+
return <BooleanInput label={fieldLabel(prop)} value={value} onChange={onChange} />
|
|
94
|
+
case 'enum':
|
|
95
|
+
return (
|
|
96
|
+
<EnumInput
|
|
97
|
+
label={fieldLabel(prop)}
|
|
98
|
+
options={type.values}
|
|
99
|
+
value={value}
|
|
100
|
+
onChange={onChange}
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
case 'literal':
|
|
104
|
+
return <LiteralDisplay label={fieldLabel(prop)} literal={type.value} />
|
|
105
|
+
case 'array':
|
|
106
|
+
return (
|
|
107
|
+
<ArrayInput
|
|
108
|
+
label={fieldLabel(prop)}
|
|
109
|
+
itemType={type.itemType}
|
|
110
|
+
itemPath={path}
|
|
111
|
+
value={value}
|
|
112
|
+
onChange={onChange}
|
|
113
|
+
errors={errors}
|
|
114
|
+
/>
|
|
115
|
+
)
|
|
116
|
+
case 'object':
|
|
117
|
+
return (
|
|
118
|
+
<ObjectInput
|
|
119
|
+
label={fieldLabel(prop)}
|
|
120
|
+
fields={type.fields}
|
|
121
|
+
value={value}
|
|
122
|
+
onChange={onChange}
|
|
123
|
+
errors={errors}
|
|
124
|
+
path={path}
|
|
125
|
+
/>
|
|
126
|
+
)
|
|
127
|
+
case 'union': {
|
|
128
|
+
const detected = detectDiscriminator(type)
|
|
129
|
+
if (detected) {
|
|
130
|
+
return (
|
|
131
|
+
<DiscriminatedUnionInput
|
|
132
|
+
label={fieldLabel(prop)}
|
|
133
|
+
union={detected}
|
|
134
|
+
value={value}
|
|
135
|
+
onChange={onChange}
|
|
136
|
+
errors={errors}
|
|
137
|
+
path={path}
|
|
138
|
+
/>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
return (
|
|
142
|
+
<JsonFallback
|
|
143
|
+
label={fieldLabel(prop)}
|
|
144
|
+
value={value}
|
|
145
|
+
onChange={onChange}
|
|
146
|
+
hint={fallbackHint(type)}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
case 'reference':
|
|
151
|
+
case 'unknown':
|
|
152
|
+
default:
|
|
153
|
+
return (
|
|
154
|
+
<JsonFallback
|
|
155
|
+
label={fieldLabel(prop)}
|
|
156
|
+
value={value}
|
|
157
|
+
onChange={onChange}
|
|
158
|
+
hint={fallbackHint(type)}
|
|
159
|
+
/>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function fieldLabel(prop: PropSpec): React.ReactNode {
|
|
165
|
+
return (
|
|
166
|
+
<>
|
|
167
|
+
<span className="font-medium">{prop.name}</span>
|
|
168
|
+
{prop.required ? <span className="ml-0.5 text-[var(--destructive)]">*</span> : null}
|
|
169
|
+
</>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function fallbackHint(type: PropType): string {
|
|
174
|
+
if (type.kind === 'reference') {
|
|
175
|
+
return `Type '${type.targetType}' could not be resolved — paste a raw JSON value for now.`
|
|
176
|
+
}
|
|
177
|
+
if (type.kind === 'union') {
|
|
178
|
+
return 'Non-discriminated union — paste a raw JSON value (the form renders a picker for unions with a shared literal tag field like `kind`).'
|
|
179
|
+
}
|
|
180
|
+
return 'Unknown type — paste a raw JSON value.'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Primitive editors ───────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
function StringInput({
|
|
186
|
+
label,
|
|
187
|
+
value,
|
|
188
|
+
onChange,
|
|
189
|
+
}: {
|
|
190
|
+
label: React.ReactNode
|
|
191
|
+
value: unknown
|
|
192
|
+
onChange: (v: unknown) => void
|
|
193
|
+
}) {
|
|
194
|
+
const stringVal = typeof value === 'string' ? value : ''
|
|
195
|
+
return (
|
|
196
|
+
<label className="block text-sm">
|
|
197
|
+
<div className="mb-1">{label}</div>
|
|
198
|
+
<input
|
|
199
|
+
type="text"
|
|
200
|
+
value={stringVal}
|
|
201
|
+
onChange={(e) => onChange(e.target.value)}
|
|
202
|
+
className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)]"
|
|
203
|
+
/>
|
|
204
|
+
</label>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function NumberInputControl({
|
|
209
|
+
label,
|
|
210
|
+
value,
|
|
211
|
+
onChange,
|
|
212
|
+
}: {
|
|
213
|
+
label: React.ReactNode
|
|
214
|
+
value: unknown
|
|
215
|
+
onChange: (v: unknown) => void
|
|
216
|
+
}) {
|
|
217
|
+
const numericVal = typeof value === 'number' ? value : ''
|
|
218
|
+
return (
|
|
219
|
+
<label className="block text-sm">
|
|
220
|
+
<div className="mb-1">{label}</div>
|
|
221
|
+
<input
|
|
222
|
+
type="number"
|
|
223
|
+
value={numericVal === '' ? '' : numericVal}
|
|
224
|
+
onChange={(e) => {
|
|
225
|
+
const raw = e.target.value
|
|
226
|
+
if (raw === '') {
|
|
227
|
+
onChange(undefined)
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
const parsed = Number(raw)
|
|
231
|
+
onChange(Number.isNaN(parsed) ? raw : parsed)
|
|
232
|
+
}}
|
|
233
|
+
className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)]"
|
|
234
|
+
/>
|
|
235
|
+
</label>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function BooleanInput({
|
|
240
|
+
label,
|
|
241
|
+
value,
|
|
242
|
+
onChange,
|
|
243
|
+
}: {
|
|
244
|
+
label: React.ReactNode
|
|
245
|
+
value: unknown
|
|
246
|
+
onChange: (v: unknown) => void
|
|
247
|
+
}) {
|
|
248
|
+
const boolVal = value === true
|
|
249
|
+
return (
|
|
250
|
+
<div className="flex items-center justify-between text-sm">
|
|
251
|
+
<span>{label}</span>
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
role="switch"
|
|
255
|
+
aria-checked={boolVal}
|
|
256
|
+
onClick={() => onChange(!boolVal)}
|
|
257
|
+
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
|
258
|
+
boolVal ? 'bg-[var(--primary)]' : 'bg-[var(--muted)]'
|
|
259
|
+
}`}
|
|
260
|
+
>
|
|
261
|
+
<span
|
|
262
|
+
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform ${
|
|
263
|
+
boolVal ? 'translate-x-5' : 'translate-x-0'
|
|
264
|
+
}`}
|
|
265
|
+
/>
|
|
266
|
+
</button>
|
|
267
|
+
</div>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function EnumInput({
|
|
272
|
+
label,
|
|
273
|
+
options,
|
|
274
|
+
value,
|
|
275
|
+
onChange,
|
|
276
|
+
}: {
|
|
277
|
+
label: React.ReactNode
|
|
278
|
+
options: (string | number)[]
|
|
279
|
+
value: unknown
|
|
280
|
+
onChange: (v: unknown) => void
|
|
281
|
+
}) {
|
|
282
|
+
const currentIdx = options.findIndex((o) => o === value)
|
|
283
|
+
return (
|
|
284
|
+
<label className="block text-sm">
|
|
285
|
+
<div className="mb-1">{label}</div>
|
|
286
|
+
<select
|
|
287
|
+
value={currentIdx === -1 ? '' : String(currentIdx)}
|
|
288
|
+
onChange={(e) => {
|
|
289
|
+
onChange(parseEnumSelection(e.target.value, options))
|
|
290
|
+
}}
|
|
291
|
+
className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)]"
|
|
292
|
+
>
|
|
293
|
+
<option value="">Select…</option>
|
|
294
|
+
{options.map((opt, idx) => (
|
|
295
|
+
<option key={String(opt)} value={idx}>
|
|
296
|
+
{String(opt)}
|
|
297
|
+
</option>
|
|
298
|
+
))}
|
|
299
|
+
</select>
|
|
300
|
+
</label>
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function LiteralDisplay({
|
|
305
|
+
label,
|
|
306
|
+
literal,
|
|
307
|
+
}: {
|
|
308
|
+
label: React.ReactNode
|
|
309
|
+
literal: string | number | boolean
|
|
310
|
+
}) {
|
|
311
|
+
return (
|
|
312
|
+
<div className="text-sm">
|
|
313
|
+
<div className="mb-1">{label}</div>
|
|
314
|
+
<div className="rounded-md border border-dashed border-[var(--border)] bg-[var(--muted)] px-3 py-2 text-xs text-[var(--muted-foreground)]">
|
|
315
|
+
Fixed: <code>{String(literal)}</code>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── Composite editors ───────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
function ObjectInput({
|
|
324
|
+
label,
|
|
325
|
+
fields,
|
|
326
|
+
value,
|
|
327
|
+
onChange,
|
|
328
|
+
errors,
|
|
329
|
+
path,
|
|
330
|
+
}: {
|
|
331
|
+
label: React.ReactNode
|
|
332
|
+
fields: PropSpec[]
|
|
333
|
+
value: unknown
|
|
334
|
+
onChange: (v: unknown) => void
|
|
335
|
+
errors: Record<string, string>
|
|
336
|
+
path: string
|
|
337
|
+
}) {
|
|
338
|
+
const obj = isPlainObject(value) ? value : {}
|
|
339
|
+
return (
|
|
340
|
+
<fieldset className="rounded-md border border-[var(--border)] bg-[var(--card)] p-3 text-sm">
|
|
341
|
+
<legend className="px-1 text-xs tracking-wide text-[var(--muted-foreground)] uppercase">
|
|
342
|
+
{label}
|
|
343
|
+
</legend>
|
|
344
|
+
<div className="space-y-3">
|
|
345
|
+
{fields.map((field) => (
|
|
346
|
+
<PropInput
|
|
347
|
+
key={field.name}
|
|
348
|
+
prop={field}
|
|
349
|
+
value={obj[field.name]}
|
|
350
|
+
onChange={(next) => onChange({ ...obj, [field.name]: next })}
|
|
351
|
+
errors={errors}
|
|
352
|
+
path={`${path}.${field.name}`}
|
|
353
|
+
/>
|
|
354
|
+
))}
|
|
355
|
+
</div>
|
|
356
|
+
</fieldset>
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function ArrayInput({
|
|
361
|
+
label,
|
|
362
|
+
itemType,
|
|
363
|
+
itemPath,
|
|
364
|
+
value,
|
|
365
|
+
onChange,
|
|
366
|
+
errors,
|
|
367
|
+
}: {
|
|
368
|
+
label: React.ReactNode
|
|
369
|
+
itemType: PropType
|
|
370
|
+
itemPath: string
|
|
371
|
+
value: unknown
|
|
372
|
+
onChange: (v: unknown) => void
|
|
373
|
+
errors: Record<string, string>
|
|
374
|
+
}) {
|
|
375
|
+
const arr = Array.isArray(value) ? value : []
|
|
376
|
+
return (
|
|
377
|
+
<div className="text-sm">
|
|
378
|
+
<div className="mb-2 flex items-center justify-between">
|
|
379
|
+
<span>{label}</span>
|
|
380
|
+
<button
|
|
381
|
+
type="button"
|
|
382
|
+
onClick={() => onChange([...arr, defaultForType(itemType)])}
|
|
383
|
+
className="rounded-md border border-[var(--border)] bg-[var(--input-background)] px-2 py-1 text-xs text-[var(--foreground)] hover:bg-[var(--accent)]"
|
|
384
|
+
>
|
|
385
|
+
+ Add
|
|
386
|
+
</button>
|
|
387
|
+
</div>
|
|
388
|
+
{arr.length === 0 ? (
|
|
389
|
+
<div className="rounded-md border border-dashed border-[var(--border)] px-3 py-4 text-center text-xs text-[var(--muted-foreground)]">
|
|
390
|
+
No items yet
|
|
391
|
+
</div>
|
|
392
|
+
) : (
|
|
393
|
+
<ol className="space-y-2">
|
|
394
|
+
{arr.map((item, idx) => (
|
|
395
|
+
<li key={idx} className="rounded-md border border-[var(--border)] bg-[var(--card)] p-3">
|
|
396
|
+
<div className="mb-2 flex items-center justify-between text-xs text-[var(--muted-foreground)]">
|
|
397
|
+
<span>Item {idx + 1}</span>
|
|
398
|
+
<button
|
|
399
|
+
type="button"
|
|
400
|
+
onClick={() => onChange(arr.filter((_, i) => i !== idx))}
|
|
401
|
+
className="text-[var(--destructive)] hover:underline"
|
|
402
|
+
>
|
|
403
|
+
Remove
|
|
404
|
+
</button>
|
|
405
|
+
</div>
|
|
406
|
+
<PropInput
|
|
407
|
+
prop={{ name: `[${idx}]`, type: itemType, required: true }}
|
|
408
|
+
value={item}
|
|
409
|
+
onChange={(next) => onChange(arr.map((v, i) => (i === idx ? next : v)))}
|
|
410
|
+
errors={errors}
|
|
411
|
+
path={`${itemPath}[${idx}]`}
|
|
412
|
+
/>
|
|
413
|
+
</li>
|
|
414
|
+
))}
|
|
415
|
+
</ol>
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function DiscriminatedUnionInput({
|
|
422
|
+
label,
|
|
423
|
+
union,
|
|
424
|
+
value,
|
|
425
|
+
onChange,
|
|
426
|
+
errors,
|
|
427
|
+
path,
|
|
428
|
+
}: {
|
|
429
|
+
label: React.ReactNode
|
|
430
|
+
union: DiscriminatedUnion
|
|
431
|
+
value: unknown
|
|
432
|
+
onChange: (v: unknown) => void
|
|
433
|
+
errors: Record<string, string>
|
|
434
|
+
path: string
|
|
435
|
+
}) {
|
|
436
|
+
const currentVariant = findVariant(value, union)
|
|
437
|
+
const obj = isPlainObject(value) ? value : {}
|
|
438
|
+
|
|
439
|
+
function switchTo(variantIdx: number) {
|
|
440
|
+
const variant = union.variants[variantIdx]
|
|
441
|
+
if (!variant) return
|
|
442
|
+
onChange(switchUnionVariant(value, union, variant))
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const currentIdx = currentVariant
|
|
446
|
+
? union.variants.findIndex((v) => v.value === currentVariant.value)
|
|
447
|
+
: -1
|
|
448
|
+
|
|
449
|
+
return (
|
|
450
|
+
<fieldset className="rounded-md border border-[var(--border)] bg-[var(--card)] p-3 text-sm">
|
|
451
|
+
<legend className="px-1 text-xs tracking-wide text-[var(--muted-foreground)] uppercase">
|
|
452
|
+
{label}
|
|
453
|
+
</legend>
|
|
454
|
+
<div className="space-y-3">
|
|
455
|
+
<label className="block text-sm">
|
|
456
|
+
<div className="mb-1">
|
|
457
|
+
<span className="font-medium">{union.field}</span>
|
|
458
|
+
<span className="ml-0.5 text-[var(--destructive)]">*</span>
|
|
459
|
+
<span className="ml-2 text-xs text-[var(--muted-foreground)]">(variant)</span>
|
|
460
|
+
</div>
|
|
461
|
+
<select
|
|
462
|
+
value={currentIdx === -1 ? '' : String(currentIdx)}
|
|
463
|
+
onChange={(e) => {
|
|
464
|
+
const idx = Number(e.target.value)
|
|
465
|
+
if (Number.isInteger(idx) && idx >= 0 && idx < union.variants.length) {
|
|
466
|
+
switchTo(idx)
|
|
467
|
+
}
|
|
468
|
+
}}
|
|
469
|
+
className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)]"
|
|
470
|
+
>
|
|
471
|
+
<option value="" disabled>
|
|
472
|
+
Select a variant…
|
|
473
|
+
</option>
|
|
474
|
+
{union.variants.map((variant, idx) => (
|
|
475
|
+
<option key={String(variant.value)} value={idx}>
|
|
476
|
+
{String(variant.value)}
|
|
477
|
+
</option>
|
|
478
|
+
))}
|
|
479
|
+
</select>
|
|
480
|
+
</label>
|
|
481
|
+
{currentVariant && currentVariant.remainingFields.length > 0 ? (
|
|
482
|
+
<div className="space-y-3">
|
|
483
|
+
{currentVariant.remainingFields.map((field) => (
|
|
484
|
+
<PropInput
|
|
485
|
+
key={field.name}
|
|
486
|
+
prop={field}
|
|
487
|
+
value={obj[field.name]}
|
|
488
|
+
onChange={(next) => onChange({ ...obj, [field.name]: next })}
|
|
489
|
+
errors={errors}
|
|
490
|
+
path={`${path}.${field.name}`}
|
|
491
|
+
/>
|
|
492
|
+
))}
|
|
493
|
+
</div>
|
|
494
|
+
) : null}
|
|
495
|
+
{!currentVariant ? (
|
|
496
|
+
<p className="text-xs text-[var(--muted-foreground)]">
|
|
497
|
+
Pick a variant to configure its fields.
|
|
498
|
+
</p>
|
|
499
|
+
) : null}
|
|
500
|
+
</div>
|
|
501
|
+
</fieldset>
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function JsonFallback({
|
|
506
|
+
label,
|
|
507
|
+
value,
|
|
508
|
+
onChange,
|
|
509
|
+
hint,
|
|
510
|
+
}: {
|
|
511
|
+
label: React.ReactNode
|
|
512
|
+
value: unknown
|
|
513
|
+
onChange: (v: unknown) => void
|
|
514
|
+
hint: string
|
|
515
|
+
}) {
|
|
516
|
+
// Treat both `undefined` and `null` as "empty" — `null` arrives as
|
|
517
|
+
// the structural default for kinds we can't seed (union / reference /
|
|
518
|
+
// unknown) and also from a JSON round-trip on `undefined`. Rendering
|
|
519
|
+
// the literal string "null" would surface as broken UX.
|
|
520
|
+
const text = value === undefined || value === null ? '' : safeJsonStringify(value)
|
|
521
|
+
return (
|
|
522
|
+
<label className="block text-sm">
|
|
523
|
+
<div className="mb-1">{label}</div>
|
|
524
|
+
<textarea
|
|
525
|
+
value={text}
|
|
526
|
+
onChange={(e) => {
|
|
527
|
+
const raw = e.target.value
|
|
528
|
+
if (raw.trim() === '') {
|
|
529
|
+
onChange(undefined)
|
|
530
|
+
return
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
onChange(JSON.parse(raw))
|
|
534
|
+
} catch {
|
|
535
|
+
// Keep the raw string so the user can finish editing without
|
|
536
|
+
// losing focus; the validator will catch it on save.
|
|
537
|
+
onChange(raw)
|
|
538
|
+
}
|
|
539
|
+
}}
|
|
540
|
+
rows={3}
|
|
541
|
+
className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-[var(--ring)]"
|
|
542
|
+
/>
|
|
543
|
+
<p className="mt-1 text-xs text-[var(--muted-foreground)]">{hint}</p>
|
|
544
|
+
</label>
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
551
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
552
|
+
}
|