@teleporthq/teleport-uidl-validator 0.44.0-alpha.0 → 0.44.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/__tests__/decoder/index.ts +480 -0
- package/__tests__/decoder/product-pages.test.ts +93 -0
- package/__tests__/decoder/project-booking-calendars.test.ts +39 -0
- package/__tests__/decoder/project-text-direction.test.ts +43 -0
- package/__tests__/decoder/project-web-app.test.ts +84 -0
- package/__tests__/decoder/seo-overrides.test.ts +99 -0
- package/__tests__/parser/compoenent-with-state-reference.json +110 -0
- package/__tests__/parser/component-parsing.ts +42 -1
- package/__tests__/parser/resource-names.ts +104 -0
- package/__tests__/validator/index.ts +35 -24
- package/__tests__/validator/project-sample.json +15 -0
- package/dist/cjs/decoders/component-decoder.js +3 -3
- package/dist/cjs/decoders/component-decoder.js.map +1 -1
- package/dist/cjs/decoders/custom-combinators.d.ts +5 -8
- package/dist/cjs/decoders/custom-combinators.d.ts.map +1 -1
- package/dist/cjs/decoders/custom-combinators.js +42 -60
- package/dist/cjs/decoders/custom-combinators.js.map +1 -1
- package/dist/cjs/decoders/project-decoder.d.ts.map +1 -1
- package/dist/cjs/decoders/project-decoder.js +76 -0
- package/dist/cjs/decoders/project-decoder.js.map +1 -1
- package/dist/cjs/decoders/utils.d.ts +142 -5
- package/dist/cjs/decoders/utils.d.ts.map +1 -1
- package/dist/cjs/decoders/utils.js +473 -47
- package/dist/cjs/decoders/utils.js.map +1 -1
- package/dist/cjs/decoders/web-app-decoder.d.ts +5 -0
- package/dist/cjs/decoders/web-app-decoder.d.ts.map +1 -0
- package/dist/cjs/decoders/web-app-decoder.js +38 -0
- package/dist/cjs/decoders/web-app-decoder.js.map +1 -0
- package/dist/cjs/parser/index.d.ts.map +1 -1
- package/dist/cjs/parser/index.js +246 -25
- package/dist/cjs/parser/index.js.map +1 -1
- package/dist/cjs/parser/resource-names.d.ts +15 -0
- package/dist/cjs/parser/resource-names.d.ts.map +1 -0
- package/dist/cjs/parser/resource-names.js +56 -0
- package/dist/cjs/parser/resource-names.js.map +1 -0
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/validator/index.d.ts.map +1 -1
- package/dist/cjs/validator/index.js.map +1 -1
- package/dist/cjs/validator/utils.d.ts.map +1 -1
- package/dist/cjs/validator/utils.js +29 -21
- package/dist/cjs/validator/utils.js.map +1 -1
- package/dist/esm/decoders/component-decoder.js +3 -3
- package/dist/esm/decoders/component-decoder.js.map +1 -1
- package/dist/esm/decoders/custom-combinators.d.ts +5 -8
- package/dist/esm/decoders/custom-combinators.d.ts.map +1 -1
- package/dist/esm/decoders/custom-combinators.js +38 -60
- package/dist/esm/decoders/custom-combinators.js.map +1 -1
- package/dist/esm/decoders/project-decoder.d.ts.map +1 -1
- package/dist/esm/decoders/project-decoder.js +78 -2
- package/dist/esm/decoders/project-decoder.js.map +1 -1
- package/dist/esm/decoders/utils.d.ts +142 -5
- package/dist/esm/decoders/utils.d.ts.map +1 -1
- package/dist/esm/decoders/utils.js +473 -47
- package/dist/esm/decoders/utils.js.map +1 -1
- package/dist/esm/decoders/web-app-decoder.d.ts +5 -0
- package/dist/esm/decoders/web-app-decoder.d.ts.map +1 -0
- package/dist/esm/decoders/web-app-decoder.js +35 -0
- package/dist/esm/decoders/web-app-decoder.js.map +1 -0
- package/dist/esm/parser/index.d.ts.map +1 -1
- package/dist/esm/parser/index.js +246 -25
- package/dist/esm/parser/index.js.map +1 -1
- package/dist/esm/parser/resource-names.d.ts +15 -0
- package/dist/esm/parser/resource-names.d.ts.map +1 -0
- package/dist/esm/parser/resource-names.js +52 -0
- package/dist/esm/parser/resource-names.js.map +1 -0
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/validator/index.d.ts.map +1 -1
- package/dist/esm/validator/index.js.map +1 -1
- package/dist/esm/validator/utils.d.ts.map +1 -1
- package/dist/esm/validator/utils.js +30 -22
- package/dist/esm/validator/utils.js.map +1 -1
- package/package.json +4 -4
- package/src/decoders/component-decoder.ts +3 -3
- package/src/decoders/custom-combinators.ts +33 -51
- package/src/decoders/project-decoder.ts +92 -1
- package/src/decoders/utils.ts +644 -63
- package/src/decoders/web-app-decoder.ts +61 -0
- package/src/parser/index.ts +334 -24
- package/src/parser/resource-names.ts +49 -0
- package/src/validator/index.ts +1 -0
- 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
|
+
})
|