@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
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Actuate Media
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Actuate Media
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Tests for the pure helpers that drive the ComponentBlockField admin
3
+ * form. Splitting these out of the React component lets us run them in
4
+ * node without jsdom.
5
+ */
6
+ export {};
7
+ //# sourceMappingURL=component-block-helpers.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"component-block-helpers.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/fields/component-block-helpers.test.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}
@@ -0,0 +1,592 @@
1
+ /**
2
+ * Tests for the pure helpers that drive the ComponentBlockField admin
3
+ * form. Splitting these out of the React component lets us run them in
4
+ * node without jsdom.
5
+ */
6
+ import { describe, expect, it } from 'vitest';
7
+ import { buildClientValidator, defaultForType, getAllowedComponents, parseEnumSelection, parsePerPropErrors, safeJsonStringify, seedPropsForComponent, switchUnionVariant, } from '../../fields/component-block-helpers.js';
8
+ import { detectDiscriminator } from '@actuate-media/component-blocks/discriminated-union';
9
+ const FIXTURE = {
10
+ version: 1,
11
+ generatedAt: '2026-05-23T12:00:00.000Z',
12
+ rootDir: 'fixtures',
13
+ components: [
14
+ {
15
+ name: 'Hero',
16
+ displayName: 'Hero',
17
+ filePath: 'Hero.tsx',
18
+ props: [
19
+ { name: 'title', type: { kind: 'string' }, required: true },
20
+ {
21
+ name: 'alignment',
22
+ type: { kind: 'enum', values: ['left', 'center', 'right'] },
23
+ required: false,
24
+ defaultValue: 'center',
25
+ },
26
+ {
27
+ name: 'features',
28
+ type: {
29
+ kind: 'array',
30
+ itemType: {
31
+ kind: 'object',
32
+ fields: [
33
+ { name: 'label', type: { kind: 'string' }, required: true },
34
+ { name: 'icon', type: { kind: 'string' }, required: false },
35
+ ],
36
+ },
37
+ },
38
+ required: false,
39
+ },
40
+ ],
41
+ },
42
+ {
43
+ name: 'Banner',
44
+ displayName: 'Banner',
45
+ filePath: 'Banner.tsx',
46
+ props: [{ name: 'message', type: { kind: 'string' }, required: true }],
47
+ },
48
+ ],
49
+ };
50
+ describe('defaultForType', () => {
51
+ it('returns empty string for string', () => {
52
+ expect(defaultForType({ kind: 'string' })).toBe('');
53
+ });
54
+ it('returns 0 for number', () => {
55
+ expect(defaultForType({ kind: 'number' })).toBe(0);
56
+ });
57
+ it('returns false for boolean', () => {
58
+ expect(defaultForType({ kind: 'boolean' })).toBe(false);
59
+ });
60
+ it('returns the first enum value', () => {
61
+ expect(defaultForType({ kind: 'enum', values: ['a', 'b'] })).toBe('a');
62
+ });
63
+ it('returns the literal value', () => {
64
+ expect(defaultForType({ kind: 'literal', value: 'fixed' })).toBe('fixed');
65
+ });
66
+ it('returns [] for array', () => {
67
+ expect(defaultForType({ kind: 'array', itemType: { kind: 'string' } })).toEqual([]);
68
+ });
69
+ it('returns an object seeded with required fields only', () => {
70
+ const result = defaultForType({
71
+ kind: 'object',
72
+ fields: [
73
+ { name: 'a', type: { kind: 'string' }, required: true },
74
+ { name: 'b', type: { kind: 'string' }, required: false },
75
+ ],
76
+ });
77
+ expect(result).toEqual({ a: '' });
78
+ });
79
+ it('returns null (not undefined) for non-discriminated union/reference/unknown so JSON round-trips are stable', () => {
80
+ // Regression for Bugbot finding: returning `undefined` from
81
+ // defaultForType inside an array contradicted the JSDoc
82
+ // ("the admin form generator never wants `undefined` in an
83
+ // array") and round-tripped through JSON.stringify as `null`
84
+ // anyway, surfacing as the literal string `null` in the
85
+ // JsonFallback textarea.
86
+ expect(defaultForType({ kind: 'union', types: [] })).toBeNull();
87
+ expect(defaultForType({ kind: 'reference', targetType: 'X' })).toBeNull();
88
+ expect(defaultForType({ kind: 'unknown' })).toBeNull();
89
+ });
90
+ it('seeds a structural default for a discriminated union (picks the first variant)', () => {
91
+ // PR #4: with a discriminated union, defaultForType picks the
92
+ // first variant and seeds its required fields. This is what lets
93
+ // the admin form render a fully-editable variant immediately
94
+ // instead of bouncing the editor through the JSON textarea.
95
+ const cta = defaultForType({
96
+ kind: 'union',
97
+ types: [
98
+ {
99
+ kind: 'object',
100
+ fields: [
101
+ { name: 'kind', type: { kind: 'literal', value: 'link' }, required: true },
102
+ { name: 'href', type: { kind: 'string' }, required: true },
103
+ { name: 'label', type: { kind: 'string' }, required: true },
104
+ ],
105
+ },
106
+ {
107
+ kind: 'object',
108
+ fields: [
109
+ { name: 'kind', type: { kind: 'literal', value: 'modal' }, required: true },
110
+ { name: 'modalId', type: { kind: 'string' }, required: true },
111
+ { name: 'label', type: { kind: 'string' }, required: true },
112
+ ],
113
+ },
114
+ ],
115
+ });
116
+ expect(cta).toEqual({ kind: 'link', href: '', label: '' });
117
+ });
118
+ });
119
+ describe('seedPropsForComponent', () => {
120
+ const hero = FIXTURE.components[0];
121
+ it('uses each prop defaultValue when present', () => {
122
+ const seeded = seedPropsForComponent(hero);
123
+ expect(seeded.alignment).toBe('center');
124
+ });
125
+ it('seeds required props without a defaultValue using defaultForType', () => {
126
+ const seeded = seedPropsForComponent(hero);
127
+ expect(seeded.title).toBe('');
128
+ });
129
+ it('omits optional props without a defaultValue', () => {
130
+ const seeded = seedPropsForComponent(hero);
131
+ // `features` is optional and has no defaultValue — should be absent.
132
+ expect('features' in seeded).toBe(false);
133
+ });
134
+ it('returns an empty object when the component has zero props', () => {
135
+ const empty = {
136
+ name: 'Empty',
137
+ displayName: 'Empty',
138
+ filePath: 'Empty.tsx',
139
+ props: [],
140
+ };
141
+ expect(seedPropsForComponent(empty)).toEqual({});
142
+ });
143
+ it('does not seed required props of non-discriminated union/reference/unknown kinds (would trip validator immediately)', () => {
144
+ // Regression for Bugbot finding: after defaultForType started
145
+ // returning `null` for these kinds (so arrays survive JSON
146
+ // round-trip), seedPropsForComponent would seed required props
147
+ // with `null` — and buildClientValidator treats `null` as
148
+ // missing, so the editor would see "Missing required prop" the
149
+ // instant they picked the component. Leave those required props
150
+ // absent instead; the validator's message and the data state
151
+ // then agree. Discriminated unions are exempt because they DO
152
+ // have a sensible structural default (first variant).
153
+ const spec = {
154
+ name: 'Card',
155
+ displayName: 'Card',
156
+ filePath: 'Card.tsx',
157
+ props: [
158
+ { name: 'title', type: { kind: 'string' }, required: true },
159
+ { name: 'data', type: { kind: 'union', types: [] }, required: true },
160
+ {
161
+ name: 'ref',
162
+ type: { kind: 'reference', targetType: 'SomeType' },
163
+ required: true,
164
+ },
165
+ { name: 'meta', type: { kind: 'unknown' }, required: true },
166
+ ],
167
+ };
168
+ const seeded = seedPropsForComponent(spec);
169
+ expect(seeded.title).toBe('');
170
+ expect('data' in seeded).toBe(false);
171
+ expect('ref' in seeded).toBe(false);
172
+ expect('meta' in seeded).toBe(false);
173
+ });
174
+ it('seeds required discriminated-union props with the first variant', () => {
175
+ // PR #4: a required cta of `{ kind: 'link', ... } | { kind: 'modal', ... }`
176
+ // should arrive on screen as a fully-editable Link variant — not
177
+ // absent, not null, not a "Missing required prop" error.
178
+ const spec = {
179
+ name: 'CTAHero',
180
+ displayName: 'CTAHero',
181
+ filePath: 'CTAHero.tsx',
182
+ props: [
183
+ {
184
+ name: 'cta',
185
+ required: true,
186
+ type: {
187
+ kind: 'union',
188
+ types: [
189
+ {
190
+ kind: 'object',
191
+ fields: [
192
+ { name: 'kind', type: { kind: 'literal', value: 'link' }, required: true },
193
+ { name: 'href', type: { kind: 'string' }, required: true },
194
+ { name: 'label', type: { kind: 'string' }, required: true },
195
+ ],
196
+ },
197
+ {
198
+ kind: 'object',
199
+ fields: [
200
+ { name: 'kind', type: { kind: 'literal', value: 'modal' }, required: true },
201
+ { name: 'modalId', type: { kind: 'string' }, required: true },
202
+ ],
203
+ },
204
+ ],
205
+ },
206
+ },
207
+ ],
208
+ };
209
+ const seeded = seedPropsForComponent(spec);
210
+ expect(seeded.cta).toEqual({ kind: 'link', href: '', label: '' });
211
+ });
212
+ });
213
+ describe('switchUnionVariant', () => {
214
+ // PR #4 review: switching between discriminated-union variants
215
+ // needs to (a) honor each field's explicit defaultValue before
216
+ // falling back to defaultForType, and (b) leave required fields
217
+ // ABSENT instead of seeding `null` when defaultForType has no
218
+ // sensible default (reference / unknown / non-discriminated union),
219
+ // so the validator's "missing required prop" message and the
220
+ // stored value agree. The previous implementation called
221
+ // defaultForType blindly and silently dropped declared defaults.
222
+ const ctaType = {
223
+ kind: 'union',
224
+ types: [
225
+ {
226
+ kind: 'object',
227
+ fields: [
228
+ { name: 'kind', type: { kind: 'literal', value: 'link' }, required: true },
229
+ { name: 'href', type: { kind: 'string' }, required: true },
230
+ { name: 'label', type: { kind: 'string' }, required: true, defaultValue: 'Learn more' },
231
+ ],
232
+ },
233
+ {
234
+ kind: 'object',
235
+ fields: [
236
+ { name: 'kind', type: { kind: 'literal', value: 'modal' }, required: true },
237
+ { name: 'modalId', type: { kind: 'string' }, required: true },
238
+ { name: 'label', type: { kind: 'string' }, required: true, defaultValue: 'Open' },
239
+ ],
240
+ },
241
+ {
242
+ kind: 'object',
243
+ fields: [
244
+ { name: 'kind', type: { kind: 'literal', value: 'page' }, required: true },
245
+ // Reference: defaultForType returns null. Required fields
246
+ // of these kinds must be left absent on variant switch.
247
+ { name: 'target', type: { kind: 'reference', targetType: 'Page' }, required: true },
248
+ { name: 'label', type: { kind: 'string' }, required: true },
249
+ ],
250
+ },
251
+ ],
252
+ };
253
+ function union() {
254
+ const detected = detectDiscriminator(ctaType);
255
+ if (!detected)
256
+ throw new Error('test fixture failed to detect discriminator');
257
+ return detected;
258
+ }
259
+ function variantFor(value) {
260
+ const v = union().variants.find((v) => v.value === value);
261
+ if (!v)
262
+ throw new Error(`test fixture missing variant '${value}'`);
263
+ return v;
264
+ }
265
+ it('seeds required fields from PropSpec.defaultValue when present', () => {
266
+ const next = switchUnionVariant({}, union(), variantFor('link'));
267
+ // `label` has defaultValue 'Learn more' — should be respected.
268
+ expect(next).toEqual({ kind: 'link', href: '', label: 'Learn more' });
269
+ });
270
+ it('falls back to defaultForType when no PropSpec.defaultValue is declared', () => {
271
+ // `href` has no defaultValue — falls back to '' (string default).
272
+ const next = switchUnionVariant({}, union(), variantFor('link'));
273
+ expect(next.href).toBe('');
274
+ });
275
+ it('does NOT seed required fields whose defaultForType is null (reference / unknown)', () => {
276
+ // `target: reference` has no sensible structural default; the
277
+ // helper must leave it ABSENT (not literal null) so the validator
278
+ // and the data agree about the missing-required state.
279
+ const next = switchUnionVariant({}, union(), variantFor('page'));
280
+ expect('target' in next).toBe(false);
281
+ // But `kind` + `label` (with their structural defaults) are present.
282
+ expect(next.kind).toBe('page');
283
+ expect(next.label).toBe('');
284
+ });
285
+ it('carries an optional/shared field across the switch when both variants declare it', () => {
286
+ // Both `link` and `modal` declare `label`; an existing value
287
+ // should survive the switch instead of being overwritten by the
288
+ // new variant's default.
289
+ const next = switchUnionVariant({ kind: 'link', href: '/x', label: 'Custom label' }, union(), variantFor('modal'));
290
+ expect(next).toEqual({ kind: 'modal', label: 'Custom label', modalId: '' });
291
+ });
292
+ it('drops fields exclusive to the previous variant', () => {
293
+ const next = switchUnionVariant({ kind: 'link', href: '/x', label: 'Custom label' }, union(), variantFor('modal'));
294
+ expect('href' in next).toBe(false);
295
+ });
296
+ it('treats a non-object input as empty when computing the next value', () => {
297
+ const next = switchUnionVariant(null, union(), variantFor('link'));
298
+ expect(next).toEqual({ kind: 'link', href: '', label: 'Learn more' });
299
+ });
300
+ it('does not override a carried defaultValue field that was edited to empty string', () => {
301
+ // The carry path runs before the seed path, so a previously-
302
+ // edited `label = ''` should win over the new variant's default.
303
+ const next = switchUnionVariant({ kind: 'link', href: '/x', label: '' }, union(), variantFor('modal'));
304
+ expect(next.label).toBe('');
305
+ });
306
+ });
307
+ describe('getAllowedComponents', () => {
308
+ it('returns all components when no allow list is given', () => {
309
+ expect(getAllowedComponents(FIXTURE, undefined)).toHaveLength(2);
310
+ });
311
+ it('returns all components when allow list is empty', () => {
312
+ expect(getAllowedComponents(FIXTURE, [])).toHaveLength(2);
313
+ });
314
+ it('filters by component name', () => {
315
+ const allowed = getAllowedComponents(FIXTURE, ['Hero']);
316
+ expect(allowed).toHaveLength(1);
317
+ expect(allowed[0].name).toBe('Hero');
318
+ });
319
+ it('returns empty array when allow list filters everything out', () => {
320
+ expect(getAllowedComponents(FIXTURE, ['Nonexistent'])).toEqual([]);
321
+ });
322
+ it('treats an empty allow array as "no filter" (matches validator semantics)', () => {
323
+ // Regression for Bugbot finding: UI used to show every component
324
+ // when allow=[] but the validator rejected every selection,
325
+ // creating a deadlock state.
326
+ expect(getAllowedComponents(FIXTURE, [])).toHaveLength(2);
327
+ });
328
+ });
329
+ describe('buildClientValidator', () => {
330
+ const validate = buildClientValidator(FIXTURE, undefined);
331
+ it('accepts a valid Hero with required props', () => {
332
+ expect(validate({ component: 'Hero', props: { title: 'Welcome' } })).toBe(true);
333
+ });
334
+ it('rejects unknown components', () => {
335
+ const res = validate({ component: 'Nope', props: {} });
336
+ expect(res).not.toBe(true);
337
+ expect(res).toMatch(/Unknown component 'Nope'/);
338
+ });
339
+ it('rejects missing required props', () => {
340
+ const res = validate({ component: 'Hero', props: {} });
341
+ expect(res).not.toBe(true);
342
+ expect(res).toMatch(/Missing required prop 'title'/);
343
+ });
344
+ it('rejects number where string expected', () => {
345
+ const res = validate({ component: 'Hero', props: { title: 42 } });
346
+ expect(res).toMatch(/Prop 'title' must be a string/);
347
+ });
348
+ it('rejects enum values not in the allowed set', () => {
349
+ const res = validate({
350
+ component: 'Hero',
351
+ props: { title: 'ok', alignment: 'diagonal' },
352
+ });
353
+ expect(res).toMatch(/Prop 'alignment' must be one of: left, center, right/);
354
+ });
355
+ it('rejects when value is not in allow-list', () => {
356
+ const restrictive = buildClientValidator(FIXTURE, ['Hero']);
357
+ const res = restrictive({ component: 'Banner', props: { message: 'hi' } });
358
+ expect(res).toMatch(/not in the allow-list/);
359
+ });
360
+ it('skips optional props that are absent', () => {
361
+ // Hero.features is optional and unset — should still pass.
362
+ const res = validate({ component: 'Hero', props: { title: 'ok' } });
363
+ expect(res).toBe(true);
364
+ });
365
+ it('accepts any component when allow=[] (empty filter matches UI)', () => {
366
+ // Regression for Bugbot finding: with the old `allow ? new Set(allow) : null`
367
+ // logic, allow=[] produced an empty Set that rejected every name.
368
+ // The UI showed every component, so saving was impossible.
369
+ const permissive = buildClientValidator(FIXTURE, []);
370
+ expect(permissive({ component: 'Hero', props: { title: 'ok' } })).toBe(true);
371
+ expect(permissive({ component: 'Banner', props: { message: 'hi' } })).toBe(true);
372
+ });
373
+ it('reports an empty-known-components message when manifest is empty', () => {
374
+ const empty = buildClientValidator({ version: 1, generatedAt: '', rootDir: '', components: [] }, undefined);
375
+ const res = empty({ component: 'Foo', props: {} });
376
+ expect(res).toMatch(/Manifest knows: <none>/);
377
+ });
378
+ describe('nested object validation', () => {
379
+ // Regression for Bugbot finding: clientShapeError used to only
380
+ // check whether the top-level value was an object, never
381
+ // recursing into nested required children. Passing `{}` for an
382
+ // object prop with required children sailed through the client
383
+ // validator while the server-side validateComponentBlockValue
384
+ // would reject it — silently broken "live structural validation".
385
+ const nested = {
386
+ version: 1,
387
+ generatedAt: '',
388
+ rootDir: '',
389
+ components: [
390
+ {
391
+ name: 'Card',
392
+ displayName: 'Card',
393
+ filePath: 'Card.tsx',
394
+ props: [
395
+ {
396
+ name: 'cta',
397
+ type: {
398
+ kind: 'object',
399
+ fields: [
400
+ { name: 'label', type: { kind: 'string' }, required: true },
401
+ { name: 'href', type: { kind: 'string' }, required: true },
402
+ { name: 'newTab', type: { kind: 'boolean' }, required: false },
403
+ ],
404
+ },
405
+ required: true,
406
+ },
407
+ ],
408
+ },
409
+ ],
410
+ };
411
+ const validateNested = buildClientValidator(nested, undefined);
412
+ it('rejects an empty object for an object prop with required children', () => {
413
+ const res = validateNested({ component: 'Card', props: { cta: {} } });
414
+ expect(res).not.toBe(true);
415
+ expect(res).toMatch(/cta\.label/);
416
+ expect(res).toMatch(/required but missing/i);
417
+ });
418
+ it('rejects a nested field whose value has the wrong type', () => {
419
+ const res = validateNested({
420
+ component: 'Card',
421
+ props: { cta: { label: 'Click me', href: 123 } },
422
+ });
423
+ expect(res).not.toBe(true);
424
+ expect(res).toMatch(/cta\.href.*must be a string/i);
425
+ });
426
+ it('accepts a fully-populated nested object', () => {
427
+ const res = validateNested({
428
+ component: 'Card',
429
+ props: { cta: { label: 'Click me', href: '/x' } },
430
+ });
431
+ expect(res).toBe(true);
432
+ });
433
+ it('allows omitting an optional nested field', () => {
434
+ const res = validateNested({
435
+ component: 'Card',
436
+ props: { cta: { label: 'Click me', href: '/x', newTab: undefined } },
437
+ });
438
+ expect(res).toBe(true);
439
+ });
440
+ });
441
+ describe('discriminated-union validation', () => {
442
+ // PR #4: with a discriminated union, the validator should route
443
+ // the value into the matching variant's shape check rather than
444
+ // accepting any object. Mirrors the picker UX one-for-one.
445
+ const dunion = {
446
+ version: 1,
447
+ generatedAt: '',
448
+ rootDir: '',
449
+ components: [
450
+ {
451
+ name: 'CTAHero',
452
+ displayName: 'CTAHero',
453
+ filePath: 'CTAHero.tsx',
454
+ props: [
455
+ {
456
+ name: 'cta',
457
+ required: true,
458
+ type: {
459
+ kind: 'union',
460
+ types: [
461
+ {
462
+ kind: 'object',
463
+ fields: [
464
+ { name: 'kind', type: { kind: 'literal', value: 'link' }, required: true },
465
+ { name: 'href', type: { kind: 'string' }, required: true },
466
+ { name: 'label', type: { kind: 'string' }, required: true },
467
+ ],
468
+ },
469
+ {
470
+ kind: 'object',
471
+ fields: [
472
+ { name: 'kind', type: { kind: 'literal', value: 'modal' }, required: true },
473
+ { name: 'modalId', type: { kind: 'string' }, required: true },
474
+ ],
475
+ },
476
+ ],
477
+ },
478
+ },
479
+ ],
480
+ },
481
+ ],
482
+ };
483
+ const validateDiscrim = buildClientValidator(dunion, undefined);
484
+ it('accepts a fully-populated valid variant', () => {
485
+ expect(validateDiscrim({
486
+ component: 'CTAHero',
487
+ props: { cta: { kind: 'link', href: '/x', label: 'Go' } },
488
+ })).toBe(true);
489
+ });
490
+ it('rejects an unknown discriminator value', () => {
491
+ const res = validateDiscrim({
492
+ component: 'CTAHero',
493
+ props: { cta: { kind: 'mystery' } },
494
+ });
495
+ expect(res).not.toBe(true);
496
+ expect(res).toMatch(/cta\.kind/);
497
+ expect(res).toMatch(/"link", "modal"/);
498
+ });
499
+ it("rejects when the picked variant's required field is missing", () => {
500
+ const res = validateDiscrim({
501
+ component: 'CTAHero',
502
+ props: { cta: { kind: 'modal' } },
503
+ });
504
+ expect(res).not.toBe(true);
505
+ expect(res).toMatch(/cta\.modalId/);
506
+ });
507
+ it('rejects when the picked variant has a wrong-type required field', () => {
508
+ const res = validateDiscrim({
509
+ component: 'CTAHero',
510
+ props: { cta: { kind: 'link', href: 42, label: 'Go' } },
511
+ });
512
+ expect(res).not.toBe(true);
513
+ expect(res).toMatch(/cta\.href.*must be a string/i);
514
+ });
515
+ it('rejects when the discriminated-union value is not an object', () => {
516
+ const res = validateDiscrim({
517
+ component: 'CTAHero',
518
+ props: { cta: 'not-an-object' },
519
+ });
520
+ expect(res).not.toBe(true);
521
+ expect(res).toMatch(/must be an object with a 'kind' discriminator/);
522
+ });
523
+ });
524
+ });
525
+ describe('safeJsonStringify', () => {
526
+ // Regression for Bugbot finding: the previous implementation
527
+ // short-circuited for strings and returned the raw value without
528
+ // quotes, which made the JsonFallback textarea round-trip lossy.
529
+ it('quotes string values so the textarea round-trips through JSON.parse', () => {
530
+ expect(safeJsonStringify('hello')).toBe('"hello"');
531
+ expect(safeJsonStringify('')).toBe('""');
532
+ });
533
+ it('encodes objects and arrays as pretty-printed JSON', () => {
534
+ expect(safeJsonStringify({ a: 1 })).toBe('{\n "a": 1\n}');
535
+ expect(safeJsonStringify([1, 2])).toBe('[\n 1,\n 2\n]');
536
+ });
537
+ it('encodes primitives correctly', () => {
538
+ expect(safeJsonStringify(42)).toBe('42');
539
+ expect(safeJsonStringify(true)).toBe('true');
540
+ expect(safeJsonStringify(null)).toBe('null');
541
+ });
542
+ it('falls back to String() for values JSON cannot encode', () => {
543
+ const circular = {};
544
+ circular.self = circular;
545
+ // The exact fallback isn't load-bearing — the contract is just
546
+ // "returns *some* string so the field remains editable".
547
+ expect(typeof safeJsonStringify(circular)).toBe('string');
548
+ });
549
+ });
550
+ describe('parseEnumSelection', () => {
551
+ it('treats the empty-string placeholder as a clear (undefined)', () => {
552
+ // Regression for Bugbot finding: the original handler ran
553
+ // `Number("")` which evaluates to 0 and silently picked the first
554
+ // option, making optional enums un-clearable.
555
+ expect(parseEnumSelection('', ['left', 'center', 'right'])).toBeUndefined();
556
+ });
557
+ it('returns the option at the parsed index', () => {
558
+ expect(parseEnumSelection('0', ['left', 'center', 'right'])).toBe('left');
559
+ expect(parseEnumSelection('2', ['left', 'center', 'right'])).toBe('right');
560
+ });
561
+ it('returns undefined for malformed or out-of-range values', () => {
562
+ expect(parseEnumSelection('not-a-number', ['a', 'b'])).toBeUndefined();
563
+ expect(parseEnumSelection('5', ['a', 'b'])).toBeUndefined();
564
+ expect(parseEnumSelection('-1', ['a', 'b'])).toBeUndefined();
565
+ expect(parseEnumSelection('1.5', ['a', 'b'])).toBeUndefined();
566
+ });
567
+ });
568
+ describe('parsePerPropErrors', () => {
569
+ it('returns empty for null/empty input', () => {
570
+ expect(parsePerPropErrors(null)).toEqual({});
571
+ expect(parsePerPropErrors('')).toEqual({});
572
+ });
573
+ it('extracts prop name from a shape error', () => {
574
+ const err = "Prop 'title' must be a string.";
575
+ expect(parsePerPropErrors(err)).toEqual({ title: err });
576
+ });
577
+ it('extracts prop name from a missing-required error', () => {
578
+ const err = "Missing required prop 'title' for component 'Hero'.";
579
+ // 'Prop' matches first since the regex sees `prop 'title'` — but the
580
+ // missing-required path is also distinct. Either keying is fine as
581
+ // long as the editor sees the message under SOMETHING. Assert at
582
+ // least one of the property keys is `title`.
583
+ const parsed = parsePerPropErrors(err);
584
+ expect(Object.keys(parsed)).toContain('title');
585
+ expect(parsed.title).toBe(err);
586
+ });
587
+ it('returns empty for whole-form errors without a prop name', () => {
588
+ const err = "Unknown component 'Nope'. Manifest knows: Hero.";
589
+ expect(parsePerPropErrors(err)).toEqual({});
590
+ });
591
+ });
592
+ //# sourceMappingURL=component-block-helpers.test.js.map