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