@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.
Files changed (37) hide show
  1. package/LICENSE +21 -21
  2. package/dist/__tests__/fields/component-block-helpers.test.d.ts +7 -0
  3. package/dist/__tests__/fields/component-block-helpers.test.d.ts.map +1 -0
  4. package/dist/__tests__/fields/component-block-helpers.test.js +592 -0
  5. package/dist/__tests__/fields/component-block-helpers.test.js.map +1 -0
  6. package/dist/fields/ComponentBlockField.d.ts +25 -0
  7. package/dist/fields/ComponentBlockField.d.ts.map +1 -0
  8. package/dist/fields/ComponentBlockField.js +74 -0
  9. package/dist/fields/ComponentBlockField.js.map +1 -0
  10. package/dist/fields/FieldRenderer.d.ts +3 -0
  11. package/dist/fields/FieldRenderer.d.ts.map +1 -1
  12. package/dist/fields/FieldRenderer.js +3 -1
  13. package/dist/fields/FieldRenderer.js.map +1 -1
  14. package/dist/fields/PropInput.d.ts +14 -0
  15. package/dist/fields/PropInput.d.ts.map +1 -0
  16. package/dist/fields/PropInput.js +163 -0
  17. package/dist/fields/PropInput.js.map +1 -0
  18. package/dist/fields/component-block-helpers.d.ts +96 -0
  19. package/dist/fields/component-block-helpers.d.ts.map +1 -0
  20. package/dist/fields/component-block-helpers.js +323 -0
  21. package/dist/fields/component-block-helpers.js.map +1 -0
  22. package/dist/fields/index.d.ts +4 -0
  23. package/dist/fields/index.d.ts.map +1 -1
  24. package/dist/fields/index.js +2 -0
  25. package/dist/fields/index.js.map +1 -1
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -0
  29. package/dist/index.js.map +1 -1
  30. package/package.json +10 -3
  31. package/src/__tests__/fields/component-block-helpers.test.ts +674 -0
  32. package/src/fields/ComponentBlockField.tsx +179 -0
  33. package/src/fields/FieldRenderer.tsx +8 -0
  34. package/src/fields/PropInput.tsx +552 -0
  35. package/src/fields/component-block-helpers.ts +341 -0
  36. package/src/fields/index.ts +4 -0
  37. 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
+ }