@teleporthq/teleport-plugin-common 0.42.35 → 0.43.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__/node-handlers/node-to-jsx/index.ts +164 -0
- package/__tests__/node-handlers/node-to-jsx/url-search-params-expression.ts +59 -0
- package/__tests__/node-handlers/node-to-jsx/utils.ts +217 -0
- package/__tests__/utils/ast-utils.ts +135 -0
- package/__tests__/utils/content-property-sanitiser.ts +156 -0
- package/__tests__/utils/url-search-params.ts +48 -0
- package/dist/cjs/builders/ast-builders.d.ts +1 -0
- package/dist/cjs/builders/ast-builders.d.ts.map +1 -1
- package/dist/cjs/builders/ast-builders.js +9 -1
- package/dist/cjs/builders/ast-builders.js.map +1 -1
- package/dist/cjs/builders/style-builders.d.ts +7 -0
- package/dist/cjs/builders/style-builders.d.ts.map +1 -1
- package/dist/cjs/builders/style-builders.js +35 -1
- package/dist/cjs/builders/style-builders.js.map +1 -1
- package/dist/cjs/node-handlers/node-to-html/index.d.ts.map +1 -1
- package/dist/cjs/node-handlers/node-to-html/index.js +19 -3
- package/dist/cjs/node-handlers/node-to-html/index.js.map +1 -1
- package/dist/cjs/node-handlers/node-to-html/utils.d.ts +2 -1
- package/dist/cjs/node-handlers/node-to-html/utils.d.ts.map +1 -1
- package/dist/cjs/node-handlers/node-to-html/utils.js +94 -11
- package/dist/cjs/node-handlers/node-to-html/utils.js.map +1 -1
- package/dist/cjs/node-handlers/node-to-jsx/index.d.ts.map +1 -1
- package/dist/cjs/node-handlers/node-to-jsx/index.js +701 -42
- package/dist/cjs/node-handlers/node-to-jsx/index.js.map +1 -1
- package/dist/cjs/node-handlers/node-to-jsx/types.d.ts +40 -2
- package/dist/cjs/node-handlers/node-to-jsx/types.d.ts.map +1 -1
- package/dist/cjs/node-handlers/node-to-jsx/utils.d.ts +50 -5
- package/dist/cjs/node-handlers/node-to-jsx/utils.d.ts.map +1 -1
- package/dist/cjs/node-handlers/node-to-jsx/utils.js +492 -35
- package/dist/cjs/node-handlers/node-to-jsx/utils.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/utils/ast-utils.d.ts +70 -4
- package/dist/cjs/utils/ast-utils.d.ts.map +1 -1
- package/dist/cjs/utils/ast-utils.js +414 -28
- package/dist/cjs/utils/ast-utils.js.map +1 -1
- package/dist/cjs/utils/style-utils.d.ts +1 -0
- package/dist/cjs/utils/style-utils.d.ts.map +1 -1
- package/dist/cjs/utils/style-utils.js +155 -2
- package/dist/cjs/utils/style-utils.js.map +1 -1
- package/dist/cjs/utils/url-search-params.d.ts +48 -0
- package/dist/cjs/utils/url-search-params.d.ts.map +1 -0
- package/dist/cjs/utils/url-search-params.js +109 -0
- package/dist/cjs/utils/url-search-params.js.map +1 -0
- package/dist/esm/builders/ast-builders.d.ts +1 -0
- package/dist/esm/builders/ast-builders.d.ts.map +1 -1
- package/dist/esm/builders/ast-builders.js +7 -0
- package/dist/esm/builders/ast-builders.js.map +1 -1
- package/dist/esm/builders/style-builders.d.ts +7 -0
- package/dist/esm/builders/style-builders.d.ts.map +1 -1
- package/dist/esm/builders/style-builders.js +33 -0
- package/dist/esm/builders/style-builders.js.map +1 -1
- package/dist/esm/node-handlers/node-to-html/index.d.ts.map +1 -1
- package/dist/esm/node-handlers/node-to-html/index.js +20 -4
- package/dist/esm/node-handlers/node-to-html/index.js.map +1 -1
- package/dist/esm/node-handlers/node-to-html/utils.d.ts +2 -1
- package/dist/esm/node-handlers/node-to-html/utils.d.ts.map +1 -1
- package/dist/esm/node-handlers/node-to-html/utils.js +93 -11
- package/dist/esm/node-handlers/node-to-html/utils.js.map +1 -1
- package/dist/esm/node-handlers/node-to-jsx/index.d.ts.map +1 -1
- package/dist/esm/node-handlers/node-to-jsx/index.js +703 -44
- package/dist/esm/node-handlers/node-to-jsx/index.js.map +1 -1
- package/dist/esm/node-handlers/node-to-jsx/types.d.ts +40 -2
- package/dist/esm/node-handlers/node-to-jsx/types.d.ts.map +1 -1
- package/dist/esm/node-handlers/node-to-jsx/utils.d.ts +50 -5
- package/dist/esm/node-handlers/node-to-jsx/utils.d.ts.map +1 -1
- package/dist/esm/node-handlers/node-to-jsx/utils.js +486 -33
- package/dist/esm/node-handlers/node-to-jsx/utils.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/utils/ast-utils.d.ts +70 -4
- package/dist/esm/utils/ast-utils.d.ts.map +1 -1
- package/dist/esm/utils/ast-utils.js +403 -27
- package/dist/esm/utils/ast-utils.js.map +1 -1
- package/dist/esm/utils/style-utils.d.ts +1 -0
- package/dist/esm/utils/style-utils.d.ts.map +1 -1
- package/dist/esm/utils/style-utils.js +153 -1
- package/dist/esm/utils/style-utils.js.map +1 -1
- package/dist/esm/utils/url-search-params.d.ts +48 -0
- package/dist/esm/utils/url-search-params.d.ts.map +1 -0
- package/dist/esm/utils/url-search-params.js +81 -0
- package/dist/esm/utils/url-search-params.js.map +1 -0
- package/package.json +4 -4
- package/src/builders/ast-builders.ts +26 -0
- package/src/builders/style-builders.ts +64 -0
- package/src/node-handlers/node-to-html/index.ts +34 -4
- package/src/node-handlers/node-to-html/utils.ts +111 -11
- package/src/node-handlers/node-to-jsx/index.ts +999 -52
- package/src/node-handlers/node-to-jsx/types.ts +48 -3
- package/src/node-handlers/node-to-jsx/utils.ts +655 -31
- package/src/utils/ast-utils.ts +547 -28
- package/src/utils/style-utils.ts +157 -1
- package/src/utils/url-search-params.ts +122 -0
|
@@ -16,8 +16,13 @@ describe('generateJSXSyntax', () => {
|
|
|
16
16
|
dependencies: {},
|
|
17
17
|
propDefinitions: uidl.propDefinitions || {},
|
|
18
18
|
stateDefinitions: uidl.stateDefinitions || {},
|
|
19
|
+
globalStateDefinitions: {},
|
|
19
20
|
nodesLookup: {},
|
|
20
21
|
windowImports: {},
|
|
22
|
+
localeReferences: [],
|
|
23
|
+
globalReferences: [],
|
|
24
|
+
globalStateReferences: [],
|
|
25
|
+
hoistedConstants: [],
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
const options: JSXGenerationOptions = {
|
|
@@ -104,4 +109,163 @@ describe('generateJSXSyntax', () => {
|
|
|
104
109
|
expect((nameAttr.value as types.StringLiteral).value).toBe('hole')
|
|
105
110
|
})
|
|
106
111
|
})
|
|
112
|
+
|
|
113
|
+
describe('renderingConditions on elements', () => {
|
|
114
|
+
it('wraps the element with a logical `&&` when renderingConditions are set', () => {
|
|
115
|
+
const ownerContainer = elementNode('container', {}, [])
|
|
116
|
+
ownerContainer.content.renderingConditions = {
|
|
117
|
+
reference: {
|
|
118
|
+
type: 'dynamic',
|
|
119
|
+
content: {
|
|
120
|
+
referenceType: 'state',
|
|
121
|
+
id: 'profileViewer',
|
|
122
|
+
refPath: ['currentUserId'],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
condition: {
|
|
126
|
+
conditions: [
|
|
127
|
+
{
|
|
128
|
+
operation: '===',
|
|
129
|
+
operand: {
|
|
130
|
+
type: 'dynamic',
|
|
131
|
+
content: {
|
|
132
|
+
referenceType: 'local',
|
|
133
|
+
id: 'id',
|
|
134
|
+
refPath: ['id'],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
} as never
|
|
141
|
+
|
|
142
|
+
const wrappingParent = elementNode('container', {}, [ownerContainer])
|
|
143
|
+
const detailsParams: JSXGenerationParams = {
|
|
144
|
+
...params,
|
|
145
|
+
globalReferences: [],
|
|
146
|
+
detailsPageExposeAsName: 'User',
|
|
147
|
+
}
|
|
148
|
+
const result = generateJSXSyntax(wrappingParent, detailsParams, options)
|
|
149
|
+
|
|
150
|
+
const expressionChild = result.children[0] as types.JSXExpressionContainer
|
|
151
|
+
expect(expressionChild.type).toBe('JSXExpressionContainer')
|
|
152
|
+
|
|
153
|
+
const logical = expressionChild.expression as types.LogicalExpression
|
|
154
|
+
expect(logical.type).toBe('LogicalExpression')
|
|
155
|
+
expect(logical.operator).toBe('&&')
|
|
156
|
+
|
|
157
|
+
// The wrapped content is the original <container/>.
|
|
158
|
+
const wrappedContent = logical.right as types.JSXElement
|
|
159
|
+
expect(wrappedContent.type).toBe('JSXElement')
|
|
160
|
+
expect((wrappedContent.openingElement.name as types.JSXIdentifier).name).toBe('container')
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('navlink href via differentiatorValue', () => {
|
|
165
|
+
// The navlink attribute flow is exercised end-to-end in the resolver tests;
|
|
166
|
+
// here we only confirm that an `expr`-typed attribute which mentions
|
|
167
|
+
// `currentUser` registers a global reference so useGlobalContext() is wired
|
|
168
|
+
// up downstream.
|
|
169
|
+
it('tracks currentUser as a global reference when emitted via an expr attr', () => {
|
|
170
|
+
const linkWithDifferentiatorHref = elementNode('a', {
|
|
171
|
+
href: {
|
|
172
|
+
type: 'expr',
|
|
173
|
+
content: '`/profile/' + '$' + '{' + 'currentUser?.id}' + '`',
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
const localParams: JSXGenerationParams = {
|
|
177
|
+
...params,
|
|
178
|
+
globalReferences: [],
|
|
179
|
+
}
|
|
180
|
+
generateJSXSyntax(linkWithDifferentiatorHref, localParams, options)
|
|
181
|
+
expect(localParams.globalReferences).toContain('currentUser')
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('plan v15 Layer 4 — codegen safety net for junk attrs and corrupt values', () => {
|
|
186
|
+
// Suppress the console.warn that fires when junk attrs are dropped so
|
|
187
|
+
// the test runner output stays clean. Reset between tests.
|
|
188
|
+
let warnSpy: jest.SpyInstance | null = null
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
191
|
+
})
|
|
192
|
+
afterEach(() => {
|
|
193
|
+
warnSpy?.mockRestore()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const attributeNames = (el: types.JSXElement): string[] =>
|
|
197
|
+
el.openingElement.attributes
|
|
198
|
+
.filter((a): a is types.JSXAttribute => a.type === 'JSXAttribute')
|
|
199
|
+
.map((a) => (a.name as types.JSXIdentifier).name)
|
|
200
|
+
|
|
201
|
+
it('drops junk-name attribute true="true"', () => {
|
|
202
|
+
const node = elementNode('div', {
|
|
203
|
+
true: { type: 'static', content: 'true' },
|
|
204
|
+
class: { type: 'static', content: 'kept' },
|
|
205
|
+
} as never)
|
|
206
|
+
const result = generateJSXSyntax(node, params, options) as types.JSXElement
|
|
207
|
+
const names = attributeNames(result)
|
|
208
|
+
expect(names).not.toContain('true')
|
|
209
|
+
expect(names).toContain('class')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('drops junk-name attribute false="false"', () => {
|
|
213
|
+
const node = elementNode('div', {
|
|
214
|
+
false: { type: 'static', content: 'false' },
|
|
215
|
+
class: { type: 'static', content: 'kept' },
|
|
216
|
+
} as never)
|
|
217
|
+
const result = generateJSXSyntax(node, params, options) as types.JSXElement
|
|
218
|
+
expect(attributeNames(result)).not.toContain('false')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('drops numeric-name attributes (e.g. 0="x")', () => {
|
|
222
|
+
const node = elementNode('div', {
|
|
223
|
+
'0': { type: 'static', content: 'x' },
|
|
224
|
+
class: { type: 'static', content: 'kept' },
|
|
225
|
+
} as never)
|
|
226
|
+
const result = generateJSXSyntax(node, params, options) as types.JSXElement
|
|
227
|
+
expect(attributeNames(result)).not.toContain('0')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('drops null and undefined name attrs', () => {
|
|
231
|
+
const node = elementNode('div', {
|
|
232
|
+
null: { type: 'static', content: 'x' },
|
|
233
|
+
undefined: { type: 'static', content: 'y' },
|
|
234
|
+
class: { type: 'static', content: 'kept' },
|
|
235
|
+
} as never)
|
|
236
|
+
const result = generateJSXSyntax(node, params, options) as types.JSXElement
|
|
237
|
+
const names = attributeNames(result)
|
|
238
|
+
expect(names).not.toContain('null')
|
|
239
|
+
expect(names).not.toContain('undefined')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('drops entity-escaped binding markers in static attr values ({)', () => {
|
|
243
|
+
const node = elementNode('tq-if', {
|
|
244
|
+
condition: { type: 'static', content: '{{state.X}}' },
|
|
245
|
+
} as never)
|
|
246
|
+
const result = generateJSXSyntax(node, params, options) as types.JSXElement
|
|
247
|
+
expect(attributeNames(result)).not.toContain('condition')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('drops static attr values with truncated `{{` (no closing `}}`)', () => {
|
|
251
|
+
const node = elementNode('tq-if', {
|
|
252
|
+
condition: { type: 'static', content: '{{state.isMobileMenuOpen' },
|
|
253
|
+
} as never)
|
|
254
|
+
const result = generateJSXSyntax(node, params, options) as types.JSXElement
|
|
255
|
+
expect(attributeNames(result)).not.toContain('condition')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('preserves well-formed static attrs (no false positives)', () => {
|
|
259
|
+
const node = elementNode('div', {
|
|
260
|
+
class: { type: 'static', content: 'foo' },
|
|
261
|
+
'data-config': { type: 'static', content: '{"k":1}' },
|
|
262
|
+
condition: { type: 'static', content: '{{state.X}}' },
|
|
263
|
+
} as never)
|
|
264
|
+
const result = generateJSXSyntax(node, params, options) as types.JSXElement
|
|
265
|
+
const names = attributeNames(result)
|
|
266
|
+
expect(names).toContain('class')
|
|
267
|
+
expect(names).toContain('data-config')
|
|
268
|
+
expect(names).toContain('condition')
|
|
269
|
+
})
|
|
270
|
+
})
|
|
107
271
|
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import generator from '@babel/generator'
|
|
2
|
+
import { createDynamicValueExpression } from '../../../src/node-handlers/node-to-jsx/utils'
|
|
3
|
+
import type { JSXGenerationOptions } from '../../../src/node-handlers/node-to-jsx/types'
|
|
4
|
+
import type { UIDLDynamicReference } from '@teleporthq/teleport-types'
|
|
5
|
+
|
|
6
|
+
const gen = (ast: unknown): string => generator(ast as never).code
|
|
7
|
+
|
|
8
|
+
const buildOptions = (
|
|
9
|
+
prefixMap: Record<string, string> = { urlSearchParams: 'router.query' },
|
|
10
|
+
registry?: Record<string, { defaultValue?: string }>
|
|
11
|
+
): JSXGenerationOptions => {
|
|
12
|
+
return {
|
|
13
|
+
dynamicReferencePrefixMap: prefixMap,
|
|
14
|
+
dependencyHandling: 'import',
|
|
15
|
+
stateHandling: 'mutation',
|
|
16
|
+
slotHandling: 'native',
|
|
17
|
+
...(registry ? { urlSearchParamsRegistry: registry } : {}),
|
|
18
|
+
} as unknown as JSXGenerationOptions
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const buildRef = (key: string): UIDLDynamicReference => ({
|
|
22
|
+
type: 'dynamic',
|
|
23
|
+
content: {
|
|
24
|
+
referenceType: 'urlSearchParams',
|
|
25
|
+
id: key,
|
|
26
|
+
refPath: [key],
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('createDynamicValueExpression — urlSearchParams', () => {
|
|
31
|
+
it('emits router.query.<key> for Next.js generators', () => {
|
|
32
|
+
const ast = createDynamicValueExpression(buildRef('category'), buildOptions())
|
|
33
|
+
expect(gen(ast)).toBe('router.query?.category')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('appends a nullish-coalescing default from the registry', () => {
|
|
37
|
+
const ast = createDynamicValueExpression(
|
|
38
|
+
buildRef('category'),
|
|
39
|
+
buildOptions({ urlSearchParams: 'router.query' }, { category: { defaultValue: 'food' } })
|
|
40
|
+
)
|
|
41
|
+
expect(gen(ast)).toBe('router.query?.category ?? "food"')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('honors a custom prefix for static generators', () => {
|
|
45
|
+
const ast = createDynamicValueExpression(
|
|
46
|
+
buildRef('category'),
|
|
47
|
+
buildOptions({ urlSearchParams: '__urlSearchParams' })
|
|
48
|
+
)
|
|
49
|
+
expect(gen(ast)).toBe('__urlSearchParams?.category')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('uses refPath[0] when no registry default is set and key has no fallback', () => {
|
|
53
|
+
const ast = createDynamicValueExpression(
|
|
54
|
+
buildRef('minPrice'),
|
|
55
|
+
buildOptions({ urlSearchParams: 'router.query' })
|
|
56
|
+
)
|
|
57
|
+
expect(gen(ast)).toBe('router.query?.minPrice')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import generate from '@babel/generator'
|
|
1
2
|
import {
|
|
2
3
|
createBinaryExpression,
|
|
3
4
|
createConditionIdentifier,
|
|
5
|
+
createStateChangeStatement,
|
|
4
6
|
} from '../../../src/node-handlers/node-to-jsx/utils'
|
|
7
|
+
import { UIDLStateDefinition, UIDLStateModifierEvent } from '@teleporthq/teleport-types'
|
|
5
8
|
import { dynamicNode } from '@teleporthq/teleport-uidl-builders'
|
|
6
9
|
import {
|
|
7
10
|
JSXGenerationParams,
|
|
@@ -122,8 +125,14 @@ describe('createConditionIdentifier', () => {
|
|
|
122
125
|
defaultValue: false,
|
|
123
126
|
},
|
|
124
127
|
},
|
|
128
|
+
globalStateDefinitions: {},
|
|
125
129
|
dependencies: {},
|
|
126
130
|
nodesLookup: {},
|
|
131
|
+
windowImports: {},
|
|
132
|
+
localeReferences: [],
|
|
133
|
+
globalReferences: [],
|
|
134
|
+
globalStateReferences: [],
|
|
135
|
+
hoistedConstants: [],
|
|
127
136
|
}
|
|
128
137
|
|
|
129
138
|
const options: JSXGenerationOptions = {
|
|
@@ -174,8 +183,216 @@ describe('createConditionIdentifier', () => {
|
|
|
174
183
|
expect(result.type).toBe('string')
|
|
175
184
|
})
|
|
176
185
|
|
|
186
|
+
it('resolves a page-root local reference against the details-page exposeAs name', () => {
|
|
187
|
+
const node = dynamicNode('local', 'id', ['id'])
|
|
188
|
+
const detailsOptions = {
|
|
189
|
+
...options,
|
|
190
|
+
dynamicReferencePrefixMap: {
|
|
191
|
+
prop: 'props',
|
|
192
|
+
state: '',
|
|
193
|
+
local: '',
|
|
194
|
+
},
|
|
195
|
+
detailsPageExposeAsName: 'User',
|
|
196
|
+
}
|
|
197
|
+
const result = createConditionIdentifier(node, params, detailsOptions)
|
|
198
|
+
|
|
199
|
+
expect(result.key).toBe('User.id')
|
|
200
|
+
expect(result.prefix).toBe('props')
|
|
201
|
+
expect(result.type).toBe('string')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('keeps repeater semantics for local refs inside a loop', () => {
|
|
205
|
+
const node = dynamicNode('local', 'item', ['type'])
|
|
206
|
+
const detailsInsideLoopOptions = {
|
|
207
|
+
...options,
|
|
208
|
+
localIdentifier: 'item',
|
|
209
|
+
detailsPageExposeAsName: 'User',
|
|
210
|
+
}
|
|
211
|
+
const result = createConditionIdentifier(node, params, detailsInsideLoopOptions)
|
|
212
|
+
|
|
213
|
+
expect(result.key).toBe('type')
|
|
214
|
+
expect(result.prefix).toBe('item')
|
|
215
|
+
})
|
|
216
|
+
|
|
177
217
|
it('throws an error for unknown reference type', () => {
|
|
178
218
|
const node = dynamicNode('locale', 'title')
|
|
179
219
|
expect(() => createConditionIdentifier(node, params, options)).toThrowError()
|
|
180
220
|
})
|
|
181
221
|
})
|
|
222
|
+
|
|
223
|
+
describe('createStateChangeStatement — plan v14 type-aware emitter', () => {
|
|
224
|
+
// We render each AST back to source so the assertions read like the code
|
|
225
|
+
// the React component would actually contain.
|
|
226
|
+
const render = (stmt: ReturnType<typeof createStateChangeStatement>): string => {
|
|
227
|
+
if (!stmt) {
|
|
228
|
+
return ''
|
|
229
|
+
}
|
|
230
|
+
return generate(stmt).code.replace(/[\s;]+$/g, '')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const hooksOptions: JSXGenerationOptions = {
|
|
234
|
+
stateHandling: 'hooks',
|
|
235
|
+
dynamicReferencePrefixMap: { prop: '', state: '', local: '' },
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const stateDefs = (
|
|
239
|
+
overrides: Record<string, UIDLStateDefinition>
|
|
240
|
+
): Record<string, UIDLStateDefinition> => ({
|
|
241
|
+
isOpen: { type: 'boolean', defaultValue: false },
|
|
242
|
+
activeStep: { type: 'number', defaultValue: 1 },
|
|
243
|
+
quizAnswers: { type: 'object', defaultValue: {} },
|
|
244
|
+
tags: { type: 'array', defaultValue: [] },
|
|
245
|
+
selectedSkinType: { type: 'string', defaultValue: '' },
|
|
246
|
+
...overrides,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('toggles a boolean state with !x (existing behaviour preserved)', () => {
|
|
250
|
+
const event: UIDLStateModifierEvent = {
|
|
251
|
+
type: 'stateChange',
|
|
252
|
+
modifies: 'isOpen',
|
|
253
|
+
newState: '$toggle',
|
|
254
|
+
}
|
|
255
|
+
const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
|
|
256
|
+
expect(render(stmt)).toBe('setIsOpen(!isOpen)')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('refuses to toggle a numeric state (no setter emitted)', () => {
|
|
260
|
+
const event: UIDLStateModifierEvent = {
|
|
261
|
+
type: 'stateChange',
|
|
262
|
+
modifies: 'activeStep',
|
|
263
|
+
newState: '$toggle',
|
|
264
|
+
}
|
|
265
|
+
const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
|
|
266
|
+
expect(stmt).toBeNull()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('refuses to toggle an object state (no setter emitted)', () => {
|
|
270
|
+
const event: UIDLStateModifierEvent = {
|
|
271
|
+
type: 'stateChange',
|
|
272
|
+
modifies: 'quizAnswers',
|
|
273
|
+
newState: '$toggle',
|
|
274
|
+
}
|
|
275
|
+
const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
|
|
276
|
+
expect(stmt).toBeNull()
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('refuses to overwrite object state with a primitive', () => {
|
|
280
|
+
const event: UIDLStateModifierEvent = {
|
|
281
|
+
type: 'stateChange',
|
|
282
|
+
modifies: 'quizAnswers',
|
|
283
|
+
newState: 'oily',
|
|
284
|
+
}
|
|
285
|
+
const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
|
|
286
|
+
expect(stmt).toBeNull()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('writes a literal value to a numeric state', () => {
|
|
290
|
+
const event: UIDLStateModifierEvent = {
|
|
291
|
+
type: 'stateChange',
|
|
292
|
+
modifies: 'activeStep',
|
|
293
|
+
newState: 2,
|
|
294
|
+
}
|
|
295
|
+
const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
|
|
296
|
+
expect(render(stmt)).toBe('setActiveStep(2)')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('writes a literal value to a string state', () => {
|
|
300
|
+
const event: UIDLStateModifierEvent = {
|
|
301
|
+
type: 'stateChange',
|
|
302
|
+
modifies: 'selectedSkinType',
|
|
303
|
+
newState: 'Oily',
|
|
304
|
+
}
|
|
305
|
+
const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
|
|
306
|
+
expect(render(stmt)).toBe('setSelectedSkinType("Oily")')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('emits a functional setter for $increment on numeric state', () => {
|
|
310
|
+
const event: UIDLStateModifierEvent = {
|
|
311
|
+
type: 'stateChange',
|
|
312
|
+
modifies: 'activeStep',
|
|
313
|
+
newState: { type: '$increment' },
|
|
314
|
+
}
|
|
315
|
+
const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
|
|
316
|
+
expect(render(stmt)).toBe('setActiveStep(prev => prev + 1)')
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('emits a functional setter for $decrement with custom delta', () => {
|
|
320
|
+
const event: UIDLStateModifierEvent = {
|
|
321
|
+
type: 'stateChange',
|
|
322
|
+
modifies: 'activeStep',
|
|
323
|
+
newState: { type: '$decrement', delta: 2 },
|
|
324
|
+
}
|
|
325
|
+
const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
|
|
326
|
+
expect(render(stmt)).toBe('setActiveStep(prev => prev - 2)')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('honors negative delta on $increment as a decrement', () => {
|
|
330
|
+
const event: UIDLStateModifierEvent = {
|
|
331
|
+
type: 'stateChange',
|
|
332
|
+
modifies: 'activeStep',
|
|
333
|
+
newState: { type: '$increment', delta: -2 },
|
|
334
|
+
}
|
|
335
|
+
const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
|
|
336
|
+
expect(render(stmt)).toBe('setActiveStep(prev => prev - 2)')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('refuses $increment on non-numeric state', () => {
|
|
340
|
+
const event: UIDLStateModifierEvent = {
|
|
341
|
+
type: 'stateChange',
|
|
342
|
+
modifies: 'selectedSkinType',
|
|
343
|
+
newState: { type: '$increment' },
|
|
344
|
+
}
|
|
345
|
+
const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
|
|
346
|
+
expect(stmt).toBeNull()
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('emits a $patch setter for an object state with a path', () => {
|
|
350
|
+
const event: UIDLStateModifierEvent = {
|
|
351
|
+
type: 'stateChange',
|
|
352
|
+
modifies: 'quizAnswers',
|
|
353
|
+
newState: { type: '$patch', path: 'skinType', value: 'Oily' },
|
|
354
|
+
}
|
|
355
|
+
const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
|
|
356
|
+
expect(render(stmt)).toBe('setQuizAnswers(prev => ({\n ...prev,\n skinType: "Oily"\n}))')
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('refuses $patch when the state is not declared as object', () => {
|
|
360
|
+
const event: UIDLStateModifierEvent = {
|
|
361
|
+
type: 'stateChange',
|
|
362
|
+
modifies: 'activeStep',
|
|
363
|
+
newState: { type: '$patch', path: 'foo', value: 'bar' },
|
|
364
|
+
}
|
|
365
|
+
const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
|
|
366
|
+
expect(stmt).toBeNull()
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('emits an $append setter for an array state', () => {
|
|
370
|
+
const event: UIDLStateModifierEvent = {
|
|
371
|
+
type: 'stateChange',
|
|
372
|
+
modifies: 'tags',
|
|
373
|
+
newState: { type: '$append', value: 'sale' },
|
|
374
|
+
}
|
|
375
|
+
const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
|
|
376
|
+
expect(render(stmt)).toBe('setTags(prev => [...prev, "sale"])')
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('refuses $append when the state is not declared as array', () => {
|
|
380
|
+
const event: UIDLStateModifierEvent = {
|
|
381
|
+
type: 'stateChange',
|
|
382
|
+
modifies: 'quizAnswers',
|
|
383
|
+
newState: { type: '$append', value: 'x' },
|
|
384
|
+
}
|
|
385
|
+
const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
|
|
386
|
+
expect(stmt).toBeNull()
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('refuses a stateChange targeting an undeclared state', () => {
|
|
390
|
+
const event: UIDLStateModifierEvent = {
|
|
391
|
+
type: 'stateChange',
|
|
392
|
+
modifies: 'doesNotExist',
|
|
393
|
+
newState: 1,
|
|
394
|
+
}
|
|
395
|
+
const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
|
|
396
|
+
expect(stmt).toBeNull()
|
|
397
|
+
})
|
|
398
|
+
})
|
|
@@ -8,7 +8,11 @@ import {
|
|
|
8
8
|
convertValueToLiteral,
|
|
9
9
|
objectToObjectExpression,
|
|
10
10
|
addRawAttributeToJSXTag,
|
|
11
|
+
collectGlobalReferencesFromObjectStates,
|
|
12
|
+
createStateHookAST,
|
|
13
|
+
sanitizeExprContent,
|
|
11
14
|
} from '../../src/utils/ast-utils'
|
|
15
|
+
import generator from '@babel/generator'
|
|
12
16
|
import ParsedASTNode from '../../src/utils/parsed-ast'
|
|
13
17
|
import { createJSXTag } from '../../src/builders/ast-builders'
|
|
14
18
|
import * as types from '@babel/types'
|
|
@@ -284,3 +288,134 @@ describe('objectToObjectExpression', () => {
|
|
|
284
288
|
})
|
|
285
289
|
})
|
|
286
290
|
})
|
|
291
|
+
|
|
292
|
+
describe('collectGlobalReferencesFromObjectStates', () => {
|
|
293
|
+
it('resolves refPath-style global references (no explicit id) via GLOBAL_REF_ID_MAP', () => {
|
|
294
|
+
const stateDefinitions = {
|
|
295
|
+
profileViewer: {
|
|
296
|
+
type: 'object',
|
|
297
|
+
defaultValue: {
|
|
298
|
+
currentUserId: {
|
|
299
|
+
type: 'dynamic',
|
|
300
|
+
content: {
|
|
301
|
+
referenceType: 'global',
|
|
302
|
+
refPath: ['Current User', 'id'],
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
const refs = collectGlobalReferencesFromObjectStates(stateDefinitions as never)
|
|
309
|
+
expect(refs).toContain('currentUser')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('resolves id-style global references untouched', () => {
|
|
313
|
+
const stateDefinitions = {
|
|
314
|
+
cartViewer: {
|
|
315
|
+
type: 'object',
|
|
316
|
+
defaultValue: {
|
|
317
|
+
itemCount: {
|
|
318
|
+
type: 'dynamic',
|
|
319
|
+
content: { referenceType: 'global', id: 'cart', refPath: ['items', 'length'] },
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
const refs = collectGlobalReferencesFromObjectStates(stateDefinitions as never)
|
|
325
|
+
expect(refs).toContain('cart')
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
describe('createStateHookAST – urlSearchParamBinding', () => {
|
|
330
|
+
const codeOf = (node: types.Node) => generator(node).code
|
|
331
|
+
|
|
332
|
+
it('emits plain useState(defaultValue) when no binding is declared', () => {
|
|
333
|
+
const decl = createStateHookAST('openPanel', {
|
|
334
|
+
type: 'boolean',
|
|
335
|
+
defaultValue: false,
|
|
336
|
+
})
|
|
337
|
+
expect(codeOf(decl)).toBe('const [openPanel, setOpenPanel] = useState(false);')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('seeds useState from window.location.search falling back to the declared default', () => {
|
|
341
|
+
// Deliberately uses `window.location.search` rather than `router.query`
|
|
342
|
+
// because on SSG pages `router.query` is empty on the first render and
|
|
343
|
+
// only hydrates after `router.isReady` flips — past that, `useState`'s
|
|
344
|
+
// initializer has already captured the fallback default. Direct reads
|
|
345
|
+
// from `window.location.search` are synchronously available on both
|
|
346
|
+
// direct loads and client-side navigations.
|
|
347
|
+
const decl = createStateHookAST('products_detail_panel_item_id', {
|
|
348
|
+
type: 'string',
|
|
349
|
+
defaultValue: '',
|
|
350
|
+
urlSearchParamBinding: { key: 'products_detail_panel_item_id' },
|
|
351
|
+
})
|
|
352
|
+
expect(codeOf(decl)).toBe(
|
|
353
|
+
'const [products_detail_panel_item_id, setProductsDetailPanelItemId] = useState((typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("products_detail_panel_item_id") : null) ?? "");'
|
|
354
|
+
)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('falls back to a non-empty static default when the param is absent', () => {
|
|
358
|
+
const decl = createStateHookAST('category', {
|
|
359
|
+
type: 'string',
|
|
360
|
+
defaultValue: 'all',
|
|
361
|
+
urlSearchParamBinding: { key: 'category' },
|
|
362
|
+
})
|
|
363
|
+
expect(codeOf(decl)).toBe(
|
|
364
|
+
'const [category, setCategory] = useState((typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("category") : null) ?? "all");'
|
|
365
|
+
)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('ignores urlSearchParamBinding with an empty key (defensive)', () => {
|
|
369
|
+
const decl = createStateHookAST('foo', {
|
|
370
|
+
type: 'string',
|
|
371
|
+
defaultValue: 'x',
|
|
372
|
+
urlSearchParamBinding: { key: '' },
|
|
373
|
+
})
|
|
374
|
+
expect(codeOf(decl)).toBe('const [foo, setFoo] = useState("x");')
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('prefers dataSourceBinding over urlSearchParamBinding when both are declared', () => {
|
|
378
|
+
const decl = createStateHookAST('mixed', {
|
|
379
|
+
type: 'string',
|
|
380
|
+
defaultValue: 'fallback',
|
|
381
|
+
dataSourceBinding: { dataSourceId: 'ds', refPath: [] },
|
|
382
|
+
urlSearchParamBinding: { key: 'mixed' },
|
|
383
|
+
})
|
|
384
|
+
// dataSourceBinding path short-circuits: props.mixed !== undefined ? props.mixed : "fallback"
|
|
385
|
+
expect(codeOf(decl)).toBe(
|
|
386
|
+
'const [mixed, setMixed] = useState(props.mixed !== undefined ? props.mixed : "fallback");'
|
|
387
|
+
)
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
describe('sanitizeExprContent', () => {
|
|
392
|
+
const originalWarn = console.warn
|
|
393
|
+
beforeEach(() => {
|
|
394
|
+
console.warn = jest.fn()
|
|
395
|
+
})
|
|
396
|
+
afterEach(() => {
|
|
397
|
+
console.warn = originalWarn
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('passes through well-formed expressions unchanged', () => {
|
|
401
|
+
const expr = '`/profile/' + '$' + '{' + 'currentUser?.id}' + '`'
|
|
402
|
+
expect(sanitizeExprContent(expr, 'href')).toBe(expr)
|
|
403
|
+
expect((console.warn as jest.Mock).mock.calls.length).toBe(0)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('repairs the /profile/${} My Profile navlink pattern', () => {
|
|
407
|
+
const expr = '`/profile/' + '$' + '{}' + '`'
|
|
408
|
+
expect(sanitizeExprContent(expr, 'href')).toBe(
|
|
409
|
+
'`/profile/' + '$' + '{' + 'currentUser?.id}' + '`'
|
|
410
|
+
)
|
|
411
|
+
expect((console.warn as jest.Mock).mock.calls.length).toBe(1)
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('repairs generic empty ${} with an empty-string placeholder', () => {
|
|
415
|
+
const expr = '`/unknown/' + '$' + '{}' + '/edit`'
|
|
416
|
+
expect(sanitizeExprContent(expr, 'href')).toBe(
|
|
417
|
+
'`/unknown/' + '$' + '{' + "'" + "'" + '}' + '/edit`'
|
|
418
|
+
)
|
|
419
|
+
expect((console.warn as jest.Mock).mock.calls.length).toBe(1)
|
|
420
|
+
})
|
|
421
|
+
})
|