@teleporthq/teleport-uidl-validator 0.44.0-alpha.0 → 0.44.1

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 (81) hide show
  1. package/__tests__/decoder/index.ts +480 -0
  2. package/__tests__/decoder/product-pages.test.ts +93 -0
  3. package/__tests__/decoder/project-booking-calendars.test.ts +39 -0
  4. package/__tests__/decoder/project-text-direction.test.ts +43 -0
  5. package/__tests__/decoder/project-web-app.test.ts +84 -0
  6. package/__tests__/decoder/seo-overrides.test.ts +99 -0
  7. package/__tests__/parser/compoenent-with-state-reference.json +110 -0
  8. package/__tests__/parser/component-parsing.ts +42 -1
  9. package/__tests__/parser/resource-names.ts +104 -0
  10. package/__tests__/validator/index.ts +35 -24
  11. package/__tests__/validator/project-sample.json +15 -0
  12. package/dist/cjs/decoders/component-decoder.js +3 -3
  13. package/dist/cjs/decoders/component-decoder.js.map +1 -1
  14. package/dist/cjs/decoders/custom-combinators.d.ts +5 -8
  15. package/dist/cjs/decoders/custom-combinators.d.ts.map +1 -1
  16. package/dist/cjs/decoders/custom-combinators.js +42 -60
  17. package/dist/cjs/decoders/custom-combinators.js.map +1 -1
  18. package/dist/cjs/decoders/project-decoder.d.ts.map +1 -1
  19. package/dist/cjs/decoders/project-decoder.js +76 -0
  20. package/dist/cjs/decoders/project-decoder.js.map +1 -1
  21. package/dist/cjs/decoders/utils.d.ts +142 -5
  22. package/dist/cjs/decoders/utils.d.ts.map +1 -1
  23. package/dist/cjs/decoders/utils.js +473 -47
  24. package/dist/cjs/decoders/utils.js.map +1 -1
  25. package/dist/cjs/decoders/web-app-decoder.d.ts +5 -0
  26. package/dist/cjs/decoders/web-app-decoder.d.ts.map +1 -0
  27. package/dist/cjs/decoders/web-app-decoder.js +38 -0
  28. package/dist/cjs/decoders/web-app-decoder.js.map +1 -0
  29. package/dist/cjs/parser/index.d.ts.map +1 -1
  30. package/dist/cjs/parser/index.js +246 -25
  31. package/dist/cjs/parser/index.js.map +1 -1
  32. package/dist/cjs/parser/resource-names.d.ts +15 -0
  33. package/dist/cjs/parser/resource-names.d.ts.map +1 -0
  34. package/dist/cjs/parser/resource-names.js +56 -0
  35. package/dist/cjs/parser/resource-names.js.map +1 -0
  36. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  37. package/dist/cjs/validator/index.d.ts.map +1 -1
  38. package/dist/cjs/validator/index.js.map +1 -1
  39. package/dist/cjs/validator/utils.d.ts.map +1 -1
  40. package/dist/cjs/validator/utils.js +29 -21
  41. package/dist/cjs/validator/utils.js.map +1 -1
  42. package/dist/esm/decoders/component-decoder.js +3 -3
  43. package/dist/esm/decoders/component-decoder.js.map +1 -1
  44. package/dist/esm/decoders/custom-combinators.d.ts +5 -8
  45. package/dist/esm/decoders/custom-combinators.d.ts.map +1 -1
  46. package/dist/esm/decoders/custom-combinators.js +38 -60
  47. package/dist/esm/decoders/custom-combinators.js.map +1 -1
  48. package/dist/esm/decoders/project-decoder.d.ts.map +1 -1
  49. package/dist/esm/decoders/project-decoder.js +78 -2
  50. package/dist/esm/decoders/project-decoder.js.map +1 -1
  51. package/dist/esm/decoders/utils.d.ts +142 -5
  52. package/dist/esm/decoders/utils.d.ts.map +1 -1
  53. package/dist/esm/decoders/utils.js +473 -47
  54. package/dist/esm/decoders/utils.js.map +1 -1
  55. package/dist/esm/decoders/web-app-decoder.d.ts +5 -0
  56. package/dist/esm/decoders/web-app-decoder.d.ts.map +1 -0
  57. package/dist/esm/decoders/web-app-decoder.js +35 -0
  58. package/dist/esm/decoders/web-app-decoder.js.map +1 -0
  59. package/dist/esm/parser/index.d.ts.map +1 -1
  60. package/dist/esm/parser/index.js +246 -25
  61. package/dist/esm/parser/index.js.map +1 -1
  62. package/dist/esm/parser/resource-names.d.ts +15 -0
  63. package/dist/esm/parser/resource-names.d.ts.map +1 -0
  64. package/dist/esm/parser/resource-names.js +52 -0
  65. package/dist/esm/parser/resource-names.js.map +1 -0
  66. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  67. package/dist/esm/validator/index.d.ts.map +1 -1
  68. package/dist/esm/validator/index.js.map +1 -1
  69. package/dist/esm/validator/utils.d.ts.map +1 -1
  70. package/dist/esm/validator/utils.js +30 -22
  71. package/dist/esm/validator/utils.js.map +1 -1
  72. package/package.json +4 -4
  73. package/src/decoders/component-decoder.ts +3 -3
  74. package/src/decoders/custom-combinators.ts +33 -51
  75. package/src/decoders/project-decoder.ts +92 -1
  76. package/src/decoders/utils.ts +644 -63
  77. package/src/decoders/web-app-decoder.ts +61 -0
  78. package/src/parser/index.ts +334 -24
  79. package/src/parser/resource-names.ts +49 -0
  80. package/src/validator/index.ts +1 -0
  81. package/src/validator/utils.ts +50 -25
@@ -0,0 +1,480 @@
1
+ import {
2
+ UIDLCMSListRepeaterNodeContent,
3
+ UIDLConditionalNode,
4
+ UIDLStateDefinition,
5
+ } from '@teleporthq/teleport-types'
6
+ import {
7
+ conditionalNodeDecoder,
8
+ stateDefinitionsDecoder,
9
+ cmsListRepeaterNodeDecoder,
10
+ renderingConditionsDecoder,
11
+ } from '../../src/decoders/utils'
12
+
13
+ test('decode conditional nodes properly when using dynamic and expression reference', () => {
14
+ const node: UIDLConditionalNode = {
15
+ type: 'conditional',
16
+ content: {
17
+ node: {
18
+ type: 'element',
19
+ content: {
20
+ elementType: 'cms-navigation-button',
21
+ semanticType: 'div',
22
+ name: 'Previous',
23
+ referencedStyles: {
24
+ TQ_Cami9r8BH2: {
25
+ type: 'style-map',
26
+ content: {
27
+ mapType: 'project-referenced',
28
+ referenceId: 'button',
29
+ },
30
+ },
31
+ },
32
+ abilities: {
33
+ link: {
34
+ type: 'navlink',
35
+ content: {
36
+ routeName: {
37
+ type: 'expr',
38
+ content:
39
+ // tslint:disable no-invalid-template-strings
40
+ "`?${new URLSearchParams({...router.query, ['cPage-5iuksa']: parseInt(router.query['cPage-5iuksa']) - 1 || 1})}`",
41
+ },
42
+ },
43
+ },
44
+ },
45
+ style: {
46
+ display: {
47
+ type: 'static',
48
+ content: 'flex',
49
+ },
50
+ },
51
+ children: [
52
+ {
53
+ type: 'element',
54
+ content: {
55
+ elementType: 'text',
56
+ referencedStyles: {},
57
+ abilities: {},
58
+ children: [
59
+ {
60
+ type: 'static',
61
+ content: 'Previous',
62
+ },
63
+ ],
64
+ },
65
+ },
66
+ ],
67
+ },
68
+ },
69
+ reference: {
70
+ type: 'expr',
71
+ content: 'params?.meta?.pagination.hasPrevPage',
72
+ },
73
+ value: true,
74
+ },
75
+ }
76
+
77
+ const result = conditionalNodeDecoder.run(node)
78
+ expect(result.ok).toBeTruthy()
79
+ })
80
+
81
+ // Regression guard for the admin-panel deep-link feature: state definitions
82
+ // declared with `urlSearchParamBinding` must survive `validateProjectSchema`
83
+ // — otherwise `createStateHookAST` never sees the binding and the generated
84
+ // `useState(...)` call silently falls back to the static default, leaving
85
+ // detail panels unable to auto-open from URLs like
86
+ // `/admin/products?products_detail_panel_item_id=<id>`. The json-type-validation
87
+ // `object(...)` decoder is strict and drops any unlisted key, so adding
88
+ // `urlSearchParamBinding` to the type without also adding it to the decoder
89
+ // would produce exactly this silent-stripping bug.
90
+ test('stateDefinitionsDecoder preserves urlSearchParamBinding round-trip', () => {
91
+ const state: UIDLStateDefinition = {
92
+ type: 'string',
93
+ defaultValue: '',
94
+ urlSearchParamBinding: { key: 'products_detail_panel_item_id' },
95
+ }
96
+
97
+ const result = stateDefinitionsDecoder.run(state)
98
+ expect(result.ok).toBeTruthy()
99
+ if (result.ok) {
100
+ expect(result.result.urlSearchParamBinding).toEqual({
101
+ key: 'products_detail_panel_item_id',
102
+ })
103
+ }
104
+ })
105
+
106
+ test('stateDefinitionsDecoder keeps a non-empty static default alongside a URL binding (fallback path)', () => {
107
+ const state: UIDLStateDefinition = {
108
+ type: 'string',
109
+ defaultValue: 'all',
110
+ urlSearchParamBinding: { key: 'category' },
111
+ }
112
+
113
+ const result = stateDefinitionsDecoder.run(state)
114
+ expect(result.ok).toBeTruthy()
115
+ if (result.ok) {
116
+ expect(result.result.defaultValue).toBe('all')
117
+ expect(result.result.urlSearchParamBinding?.key).toBe('category')
118
+ }
119
+ })
120
+
121
+ test('stateDefinitionsDecoder rejects urlSearchParamBinding that is missing the required `key`', () => {
122
+ const state = {
123
+ type: 'string',
124
+ defaultValue: '',
125
+ urlSearchParamBinding: {},
126
+ } as unknown as UIDLStateDefinition
127
+
128
+ const result = stateDefinitionsDecoder.run(state)
129
+ expect(result.ok).toBeFalsy()
130
+ })
131
+
132
+ /**
133
+ * ⛔ THE decoder hazard, stated once and enforced for the whole node.
134
+ *
135
+ * `object()` silently DROPS any key absent from its schema, and the decoded UIDL
136
+ * REPLACES the input for the rest of the pipeline — so a field the type and the
137
+ * code generator both know about, but the decoder does not, simply ceases to
138
+ * exist somewhere in the middle with nothing logged. It has cost three shipped
139
+ * defects on this one node: `searchUrlParamKey` (URL search sync dead), `cache`
140
+ * (every published list uncached) and `isLoading` (a state-backed list showed
141
+ * "no results" for the whole fetch — its loading branch was still generated for
142
+ * its STYLES, so the skeleton CSS was in the output and everything looked wired).
143
+ *
144
+ * This map is exhaustive by construction: add a field to
145
+ * `UIDLCMSListRepeaterNodeContent` and this file stops COMPILING until you have
146
+ * said which side it falls on, and adding it as `preserved` then fails the test
147
+ * below until the decoder knows about it too.
148
+ */
149
+ const REPEATER_CONTENT_FIELDS: Record<
150
+ keyof UIDLCMSListRepeaterNodeContent,
151
+ 'preserved' | 'assigned-after-validation'
152
+ > = {
153
+ elementType: 'preserved',
154
+ name: 'preserved',
155
+ nodeId: 'preserved',
156
+ attrs: 'preserved',
157
+ dependency: 'preserved',
158
+ nodes: 'preserved',
159
+ renderPropIdentifier: 'preserved',
160
+ source: 'preserved',
161
+ paginated: 'preserved',
162
+ perPage: 'preserved',
163
+ searchEnabled: 'preserved',
164
+ searchDebounce: 'preserved',
165
+ searchDefaultValue: 'preserved',
166
+ searchUrlParamKey: 'preserved',
167
+ sort: 'preserved',
168
+ isLoading: 'preserved',
169
+ sortDirection: 'preserved',
170
+ cache: 'preserved',
171
+ paginationMode: 'preserved',
172
+ infiniteScroll: 'preserved',
173
+ infiniteScrollLoadMore: 'preserved',
174
+ pageUrlParamKey: 'preserved',
175
+ // Stamped by the generator AFTER validation, never carried in from the UIDL.
176
+ key: 'assigned-after-validation',
177
+ }
178
+
179
+ test('cmsListRepeaterNodeDecoder preserves every content field the type declares', () => {
180
+ const sample: Record<string, unknown> = {
181
+ elementType: 'container',
182
+ name: 'cms-list-repeater',
183
+ nodeId: 'node-1',
184
+ attrs: { dataNodeId: { type: 'static', content: 'node-1' } },
185
+ dependency: {
186
+ type: 'package',
187
+ path: '@teleporthq/react-components',
188
+ version: 'latest',
189
+ meta: { namedImport: true },
190
+ },
191
+ nodes: {
192
+ list: { type: 'element', content: { elementType: 'container' } },
193
+ empty: { type: 'element', content: { elementType: 'container' } },
194
+ loading: { type: 'element', content: { elementType: 'container' } },
195
+ },
196
+ renderPropIdentifier: 'favouriteProduct',
197
+ source: 'favouriteProducts',
198
+ paginated: true,
199
+ perPage: 20,
200
+ searchEnabled: true,
201
+ searchDebounce: 300,
202
+ searchDefaultValue: { type: 'static', content: 'shoes' },
203
+ searchUrlParamKey: 'searchKeyword',
204
+ sort: { type: 'static', content: 'name' },
205
+ sortDirection: { type: 'static', content: 'asc' },
206
+ isLoading: { type: 'expr', content: "isLoadingFavourites || ''" },
207
+ cache: { enabled: true, ttlSeconds: 300, client: true, server: true },
208
+ paginationMode: 'numbered',
209
+ infiniteScroll: false,
210
+ infiniteScrollLoadMore: false,
211
+ pageUrlParamKey: 'page',
212
+ }
213
+
214
+ const expected = Object.entries(REPEATER_CONTENT_FIELDS)
215
+ .filter(([, kind]) => kind === 'preserved')
216
+ .map(([field]) => field)
217
+
218
+ // Every preserved field must be exercised — a field left out of `sample`
219
+ // would pass the round trip below without ever being decoded.
220
+ expect(Object.keys(sample).sort()).toEqual([...expected].sort())
221
+
222
+ const result = cmsListRepeaterNodeDecoder.run({ type: 'cms-list-repeater', content: sample })
223
+ expect(result.ok).toBeTruthy()
224
+ if (result.ok) {
225
+ const content = result.result.content as Record<string, unknown>
226
+ // Compared as a SET, not field by field: a new field that the decoder drops
227
+ // has to fail here rather than wait for someone to add an assertion for it.
228
+ expect(Object.keys(content).sort()).toEqual([...expected].sort())
229
+ expect(content.isLoading).toEqual({ type: 'expr', content: "isLoadingFavourites || ''" })
230
+ }
231
+ })
232
+
233
+ test('cmsListRepeaterNodeDecoder preserves searchUrlParamKey through validation', () => {
234
+ // Regression guard for the "searchKeyword in the URL is ignored" bug: the
235
+ // `object()` decoder silently DROPS any key absent from its schema, so the
236
+ // products-list search input's `searchUrlParamKey` must stay listed or its
237
+ // two-way URL sync dies before the pagination plugin ever sees it.
238
+ const node = {
239
+ type: 'cms-list-repeater',
240
+ content: {
241
+ elementType: 'container',
242
+ name: 'cms-list-repeater',
243
+ renderPropIdentifier: 'ecommerceProduct',
244
+ paginated: true,
245
+ perPage: 20,
246
+ searchEnabled: true,
247
+ searchDebounce: 300,
248
+ searchUrlParamKey: 'searchKeyword',
249
+ nodes: {
250
+ list: { type: 'element', content: { elementType: 'container' } },
251
+ },
252
+ },
253
+ }
254
+
255
+ const result = cmsListRepeaterNodeDecoder.run(node)
256
+ expect(result.ok).toBeTruthy()
257
+ if (result.ok) {
258
+ // The validated `VCMSListRepeaterElementNode` content type narrows to
259
+ // `{ nodes }`, so read the preserved scalar fields off the runtime shape.
260
+ const content = result.result.content as {
261
+ searchUrlParamKey?: string
262
+ searchEnabled?: boolean
263
+ searchDebounce?: number
264
+ }
265
+ expect(content.searchUrlParamKey).toBe('searchKeyword')
266
+ // The sibling search fields still decode alongside it.
267
+ expect(content.searchEnabled).toBe(true)
268
+ expect(content.searchDebounce).toBe(300)
269
+ }
270
+ })
271
+
272
+ test('cmsListRepeaterNodeDecoder preserves the pagination shape fields', () => {
273
+ const node = {
274
+ type: 'cms-list-repeater',
275
+ content: {
276
+ elementType: 'container',
277
+ name: 'cms-list-repeater',
278
+ renderPropIdentifier: 'ecommerceProduct',
279
+ paginated: true,
280
+ perPage: 20,
281
+ paginationMode: 'numbered',
282
+ pageUrlParamKey: 'page',
283
+ nodes: {
284
+ list: { type: 'element', content: { elementType: 'container' } },
285
+ },
286
+ },
287
+ }
288
+
289
+ const result = cmsListRepeaterNodeDecoder.run(node)
290
+ expect(result.ok).toBeTruthy()
291
+ if (result.ok) {
292
+ const content = result.result.content as {
293
+ paginationMode?: string
294
+ pageUrlParamKey?: string
295
+ }
296
+ expect(content.paginationMode).toBe('numbered')
297
+ expect(content.pageUrlParamKey).toBe('page')
298
+ }
299
+ })
300
+
301
+ test('cmsListRepeaterNodeDecoder rejects a paginationMode outside the union', () => {
302
+ const node = {
303
+ type: 'cms-list-repeater',
304
+ content: {
305
+ elementType: 'container',
306
+ name: 'cms-list-repeater',
307
+ renderPropIdentifier: 'ecommerceProduct',
308
+ paginationMode: 'carousel',
309
+ nodes: {
310
+ list: { type: 'element', content: { elementType: 'container' } },
311
+ },
312
+ },
313
+ }
314
+
315
+ expect(cmsListRepeaterNodeDecoder.run(node).ok).toBeFalsy()
316
+ })
317
+
318
+ test('cmsListRepeaterNodeDecoder preserves the infinite-scroll flags', () => {
319
+ const node = {
320
+ type: 'cms-list-repeater',
321
+ content: {
322
+ elementType: 'container',
323
+ name: 'cms-list-repeater',
324
+ renderPropIdentifier: 'ecommerceProduct',
325
+ paginated: true,
326
+ perPage: 20,
327
+ infiniteScroll: true,
328
+ infiniteScrollLoadMore: true,
329
+ nodes: {
330
+ list: { type: 'element', content: { elementType: 'container' } },
331
+ },
332
+ },
333
+ }
334
+
335
+ const result = cmsListRepeaterNodeDecoder.run(node)
336
+ expect(result.ok).toBeTruthy()
337
+ if (result.ok) {
338
+ const content = result.result.content as {
339
+ infiniteScroll?: boolean
340
+ infiniteScrollLoadMore?: boolean
341
+ }
342
+ expect(content.infiniteScroll).toBe(true)
343
+ expect(content.infiniteScrollLoadMore).toBe(true)
344
+ }
345
+ })
346
+
347
+ // Same silent-stripping hazard as above, for rendering conditions: the decoded
348
+ // UIDL REPLACES the input in every generator pipeline, so a condition-entry
349
+ // field missing from the decoder never reaches codegen. `containsField` was
350
+ // stripped this way (contains-on-object-field degraded to plain contains in
351
+ // every published app); per-entry `reference` would break far louder — the
352
+ // entry would silently compare the WRONG state.
353
+ test('renderingConditionsDecoder preserves containsField and per-entry references', () => {
354
+ const renderingConditions = {
355
+ reference: {
356
+ type: 'dynamic',
357
+ content: { referenceType: 'state', id: 'newsletterOfferVisible' },
358
+ },
359
+ condition: {
360
+ conditions: [
361
+ { operation: '===', operand: true },
362
+ {
363
+ operation: '===',
364
+ operand: false,
365
+ reference: {
366
+ type: 'dynamic',
367
+ content: { referenceType: 'state', id: 'userIsLoggedIn' },
368
+ },
369
+ },
370
+ { operation: 'contains', operand: 'tag-1', containsField: 'tags' },
371
+ ],
372
+ matchingCriteria: 'all',
373
+ },
374
+ }
375
+
376
+ const result = renderingConditionsDecoder.run(renderingConditions)
377
+ expect(result.ok).toBeTruthy()
378
+ if (result.ok) {
379
+ const decodedConditions = result.result.condition.conditions as Array<{
380
+ operation: string
381
+ containsField?: string
382
+ reference?: { type: string; content: { id: string } }
383
+ }>
384
+ expect(decodedConditions[1].reference?.content.id).toBe('userIsLoggedIn')
385
+ expect(decodedConditions[2].containsField).toBe('tags')
386
+ expect(result.result.condition.matchingCriteria).toBe('all')
387
+ }
388
+ })
389
+
390
+ test('renderingConditionsDecoder preserves nested condition groups — (a && b) || c', () => {
391
+ const renderingConditions = {
392
+ reference: {
393
+ type: 'dynamic',
394
+ content: { referenceType: 'state', id: 'a' },
395
+ },
396
+ condition: {
397
+ conditions: [
398
+ {
399
+ conditions: [
400
+ { operation: '===', operand: 1 },
401
+ {
402
+ operation: '===',
403
+ operand: 3,
404
+ reference: { type: 'dynamic', content: { referenceType: 'state', id: 'b' } },
405
+ },
406
+ ],
407
+ matchingCriteria: 'all',
408
+ },
409
+ {
410
+ operation: '===',
411
+ operand: 5,
412
+ reference: { type: 'dynamic', content: { referenceType: 'state', id: 'c' } },
413
+ },
414
+ ],
415
+ matchingCriteria: '||',
416
+ },
417
+ }
418
+
419
+ const result = renderingConditionsDecoder.run(renderingConditions)
420
+ expect(result.ok).toBeTruthy()
421
+ if (result.ok) {
422
+ const decodedConditions = result.result.condition.conditions as Array<{
423
+ operation?: string
424
+ matchingCriteria?: string
425
+ conditions?: Array<{
426
+ operation: string
427
+ operand?: unknown
428
+ reference?: { type: string; content: { id: string } }
429
+ }>
430
+ reference?: { type: string; content: { id: string } }
431
+ }>
432
+ expect(decodedConditions[0].conditions).toHaveLength(2)
433
+ expect(decodedConditions[0].matchingCriteria).toBe('all')
434
+ expect(decodedConditions[0].conditions?.[1].reference?.content.id).toBe('b')
435
+ expect(decodedConditions[1].operation).toBe('===')
436
+ expect(decodedConditions[1].reference?.content.id).toBe('c')
437
+ expect(result.result.condition.matchingCriteria).toBe('||')
438
+ }
439
+ })
440
+
441
+ // The decoder lagged behind `ReferenceType`: an entry reading the page address
442
+ // (`?searchKeyword=`) failed the whole project's validation, so no generator
443
+ // could ever reach the code that reads it as `router.query.<key>`.
444
+ test('renderingConditionsDecoder accepts an entry that reads a URL search param', () => {
445
+ const renderingConditions = {
446
+ reference: {
447
+ type: 'dynamic',
448
+ content: { referenceType: 'state', id: 'selectedCategory' },
449
+ },
450
+ condition: {
451
+ conditions: [
452
+ { operation: 'isEmpty' },
453
+ {
454
+ operation: 'isEmpty',
455
+ reference: {
456
+ type: 'dynamic',
457
+ content: {
458
+ referenceType: 'urlSearchParams',
459
+ id: 'searchKeyword',
460
+ refPath: ['searchKeyword'],
461
+ },
462
+ },
463
+ },
464
+ ],
465
+ matchingCriteria: 'all',
466
+ },
467
+ }
468
+
469
+ const result = renderingConditionsDecoder.run(renderingConditions)
470
+ expect(result.ok).toBeTruthy()
471
+ if (result.ok) {
472
+ const [, searchEntry] = result.result.condition.conditions as Array<{
473
+ reference?: { content: { referenceType: string; id: string } }
474
+ }>
475
+ expect(searchEntry.reference?.content).toMatchObject({
476
+ referenceType: 'urlSearchParams',
477
+ id: 'searchKeyword',
478
+ })
479
+ }
480
+ })
@@ -0,0 +1,93 @@
1
+ import { initialPropsDecoder, navLinkNodeDecoder } from '../../src/decoders/utils'
2
+
3
+ // Custom product pages: the object decoders are strict whitelists and the
4
+ // decoded UIDL REPLACES the input, so every field the feature adds is pinned
5
+ // here — a decoder that dropped one would silently turn the feature off.
6
+
7
+ describe('initialPropsDecoder: a page that shows a subset of the rows', () => {
8
+ const base = {
9
+ exposeAs: { name: 'ecommerceProduct', valuePath: ['data', '0'] },
10
+ resource: { id: 'TQ_teleport_productsDetail' },
11
+ }
12
+
13
+ it('keeps the guard and the status of the redirect', () => {
14
+ const decoded = initialPropsDecoder.runWithException({
15
+ ...base,
16
+ redirect: {
17
+ destinationField: 'productPageUrl',
18
+ unlessFieldEquals: { field: 'productPageKey', value: 'personalise' },
19
+ statusCode: 302,
20
+ },
21
+ })
22
+
23
+ expect(decoded.redirect).toEqual({
24
+ destinationField: 'productPageUrl',
25
+ unlessFieldEquals: { field: 'productPageKey', value: 'personalise' },
26
+ statusCode: 302,
27
+ })
28
+ })
29
+
30
+ it('keeps the filter that selects the page’s own rows', () => {
31
+ const ownRowsFilter = [
32
+ {
33
+ type: 'group',
34
+ operator: 'or',
35
+ children: [
36
+ { type: 'condition', source: 'product_page', destination: null, operand: '=' },
37
+ { type: 'condition', source: 'product_page', destination: '', operand: '=' },
38
+ ],
39
+ },
40
+ ]
41
+ const decoded = initialPropsDecoder.runWithException({
42
+ ...base,
43
+ redirect: {
44
+ destinationField: 'productPageUrl',
45
+ unlessFieldEquals: { field: 'productPageKey', value: '' },
46
+ ownRowsFilter,
47
+ statusCode: 302,
48
+ },
49
+ })
50
+ expect(decoded.redirect?.ownRowsFilter).toEqual(ownRowsFilter)
51
+ })
52
+
53
+ it('refuses a status that is not a redirect', () => {
54
+ expect(() =>
55
+ initialPropsDecoder.runWithException({
56
+ ...base,
57
+ redirect: { destinationField: 'productPageUrl', statusCode: 200 },
58
+ })
59
+ ).toThrow()
60
+ })
61
+ })
62
+
63
+ describe('navLinkNodeDecoder: the canonical address of the linked record', () => {
64
+ it('keeps canonicalValue next to the differentiator', () => {
65
+ const decoded = navLinkNodeDecoder.runWithException({
66
+ type: 'navlink',
67
+ content: {
68
+ routeName: '/products',
69
+ differentiatorValue: {
70
+ type: 'dynamic',
71
+ content: { referenceType: 'prop', id: 'product', refPath: ['slug'] },
72
+ },
73
+ canonicalValue: {
74
+ type: 'dynamic',
75
+ content: { referenceType: 'prop', id: 'product', refPath: ['productPageUrl'] },
76
+ },
77
+ },
78
+ })
79
+
80
+ expect(decoded.content.canonicalValue).toEqual({
81
+ type: 'dynamic',
82
+ content: { referenceType: 'prop', id: 'product', refPath: ['productPageUrl'] },
83
+ })
84
+ })
85
+
86
+ it('decodes a navlink without one exactly as before', () => {
87
+ const decoded = navLinkNodeDecoder.runWithException({
88
+ type: 'navlink',
89
+ content: { routeName: '/about' },
90
+ })
91
+ expect(decoded.content).toEqual({ routeName: '/about' })
92
+ })
93
+ })
@@ -0,0 +1,39 @@
1
+ import { projectUIDLDecoder } from '../../src/decoders/project-decoder'
2
+ import projectSample from '../../../../examples/test-samples/project-sample.json'
3
+
4
+ /**
5
+ * ⛔ THE DECODER IS THE SCHEMA: a top-level field it does not name is dropped,
6
+ * and the generator only ever sees the decoded UIDL. The bookings' calendar
7
+ * feed route is emitted by NextWorkflowProjectPlugin only when this entry
8
+ * reaches it.
9
+ */
10
+ const decode = (extra: Record<string, unknown>) =>
11
+ projectUIDLDecoder.runWithException({ ...projectSample, ...extra }) as unknown as Record<
12
+ string,
13
+ unknown
14
+ >
15
+
16
+ const FEED_STATEMENT = 'SELECT * FROM booking_calendar_feed($1)'
17
+
18
+ describe('project decoder — booking calendars', () => {
19
+ it('keeps the feed statement', () => {
20
+ expect(
21
+ decode({ bookingCalendars: { feedStatement: FEED_STATEMENT } }).bookingCalendars
22
+ ).toEqual({ feedStatement: FEED_STATEMENT })
23
+ })
24
+
25
+ it('drops keys the schema does not name', () => {
26
+ expect(
27
+ decode({ bookingCalendars: { feedStatement: FEED_STATEMENT, links: ['a'] } }).bookingCalendars
28
+ ).toEqual({ feedStatement: FEED_STATEMENT })
29
+ })
30
+
31
+ it('rejects booking calendars without a statement', () => {
32
+ expect(() => decode({ bookingCalendars: {} })).toThrow()
33
+ expect(() => decode({ bookingCalendars: { feedStatement: 42 } })).toThrow()
34
+ })
35
+
36
+ it('leaves a project without booking calendars untouched', () => {
37
+ expect(decode({}).bookingCalendars).toBeUndefined()
38
+ })
39
+ })
@@ -0,0 +1,43 @@
1
+ import { globalProjectValuesDecoder } from '../../src/decoders/project-decoder'
2
+
3
+ /**
4
+ * ⛔ THE DECODER IS THE SCHEMA.
5
+ *
6
+ * `object()` drops every key it does not name, and the project generator
7
+ * REPLACES the input UIDL with the decoded result (`cleanedUIDL = projectUIDL`).
8
+ * A field added to the types and written by the editor but missed here reaches
9
+ * the generator as `undefined` — with nothing logged, nothing failing, and the
10
+ * feature simply absent from every published site.
11
+ *
12
+ * The writing-direction fields are exactly that shape: absent for almost every
13
+ * project, so a silent drop would only ever be noticed by the handful of
14
+ * merchants who need them.
15
+ */
16
+ describe('project globals decoder — writing direction', () => {
17
+ const decode = (settings: Record<string, unknown>) =>
18
+ globalProjectValuesDecoder.runWithException({
19
+ settings,
20
+ meta: [],
21
+ assets: [],
22
+ }).settings as Record<string, unknown>
23
+
24
+ it('keeps a right-to-left project direction', () => {
25
+ expect(decode({ title: 'Shop', language: 'ar', dir: 'rtl' }).dir).toBe('rtl')
26
+ })
27
+
28
+ it('keeps the RTL locale list of an internationalized project', () => {
29
+ expect(decode({ title: 'Shop', language: 'en', rtlLocales: ['ar', 'he'] }).rtlLocales).toEqual([
30
+ 'ar',
31
+ 'he',
32
+ ])
33
+ })
34
+
35
+ it('leaves an ordinary project untouched', () => {
36
+ const settings = decode({ title: 'Shop', language: 'en' })
37
+ expect(settings).toEqual({ title: 'Shop', language: 'en' })
38
+ })
39
+
40
+ it('rejects a direction that is neither', () => {
41
+ expect(() => decode({ title: 'Shop', language: 'en', dir: 'sideways' })).toThrow()
42
+ })
43
+ })