@teleporthq/teleport-project-generator-next 0.43.44 → 0.43.45
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__/state-data-source-runtime-user-fetch.test.ts +268 -0
- package/dist/cjs/state-data-source-plugin.d.ts.map +1 -1
- package/dist/cjs/state-data-source-plugin.js +351 -23
- package/dist/cjs/state-data-source-plugin.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/state-data-source-plugin.d.ts.map +1 -1
- package/dist/esm/state-data-source-plugin.js +352 -24
- package/dist/esm/state-data-source-plugin.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/state-data-source-plugin.ts +526 -31
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import generator from '@babel/generator'
|
|
2
|
+
import * as types from '@babel/types'
|
|
3
|
+
import { createStateDataSourcePlugin } from '../src/state-data-source-plugin'
|
|
4
|
+
|
|
5
|
+
// Regression guard for the "Characters page renders empty lists" defect
|
|
6
|
+
// (GuildForge run, examples/uidl-samples/project.json): page states bound to a
|
|
7
|
+
// data source with a `{{Current User.*}}` query were silently dropped — no
|
|
8
|
+
// getStaticProps fetch is possible (the placeholder only resolves at runtime)
|
|
9
|
+
// and nothing else populated them, so every list bound to such a state stayed
|
|
10
|
+
// empty forever. The plugin must instead emit an API route + a client-side
|
|
11
|
+
// useEffect fetch keyed on the signed-in user.
|
|
12
|
+
|
|
13
|
+
const DS_ID = '8e4bd2e9-1ceb-41ee-91ea-6f2aca94076e'
|
|
14
|
+
|
|
15
|
+
const USER_QUERY =
|
|
16
|
+
"SELECT id, user_id, name FROM characters WHERE (user_id = '{{Current User.id}}' OR user_id IS NULL) ORDER BY name ASC LIMIT 20"
|
|
17
|
+
|
|
18
|
+
const makeState = (query?: string) => ({
|
|
19
|
+
type: 'array',
|
|
20
|
+
defaultValue: [] as unknown[],
|
|
21
|
+
dataSourceBinding: {
|
|
22
|
+
dataSourceId: DS_ID,
|
|
23
|
+
refPath: ['characters'],
|
|
24
|
+
},
|
|
25
|
+
...(query ? { query } : {}),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const makeJsxComponentChunk = () => ({
|
|
29
|
+
type: 'AST',
|
|
30
|
+
fileType: 'js',
|
|
31
|
+
name: 'jsx-component',
|
|
32
|
+
linkAfter: [] as string[],
|
|
33
|
+
content: types.variableDeclaration('const', [
|
|
34
|
+
types.variableDeclarator(
|
|
35
|
+
types.identifier('Characters'),
|
|
36
|
+
types.arrowFunctionExpression(
|
|
37
|
+
[],
|
|
38
|
+
types.blockStatement([types.returnStatement(types.nullLiteral())])
|
|
39
|
+
)
|
|
40
|
+
),
|
|
41
|
+
]),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const makeStructure = (
|
|
45
|
+
stateDefinitions: Record<string, unknown>,
|
|
46
|
+
{ auth = true, dynamicRouteAttribute }: { auth?: boolean; dynamicRouteAttribute?: string } = {}
|
|
47
|
+
) => ({
|
|
48
|
+
uidl: {
|
|
49
|
+
name: 'Characters',
|
|
50
|
+
stateDefinitions,
|
|
51
|
+
node: { type: 'element', content: { elementType: 'container' } },
|
|
52
|
+
outputOptions: {
|
|
53
|
+
folderPath: [] as string[],
|
|
54
|
+
...(dynamicRouteAttribute && { dynamicRouteAttribute }),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
chunks: [makeJsxComponentChunk()] as any[],
|
|
58
|
+
dependencies: {} as Record<string, unknown>,
|
|
59
|
+
options: {
|
|
60
|
+
dataSources: {
|
|
61
|
+
[DS_ID]: {
|
|
62
|
+
id: DS_ID,
|
|
63
|
+
type: 'teleport',
|
|
64
|
+
config: {},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
extractedResources: {} as Record<string, unknown>,
|
|
68
|
+
...(auth ? { auth: { provider: 'teleport' } } : {}),
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const componentCode = (structure: { chunks: any[] }): string => {
|
|
73
|
+
const chunk = structure.chunks.find((c) => c.name === 'jsx-component')
|
|
74
|
+
return generator(chunk.content as types.Node).code
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('state-data-source-plugin runtime {{Current User.*}} fetch', () => {
|
|
78
|
+
const plugin = createStateDataSourcePlugin()
|
|
79
|
+
|
|
80
|
+
it('emits an API route + useEffect fetch for a Current-User-bound state', async () => {
|
|
81
|
+
const structure = makeStructure({ charactersResults: makeState(USER_QUERY) })
|
|
82
|
+
|
|
83
|
+
const result = await plugin(structure as any)
|
|
84
|
+
|
|
85
|
+
// API route extracted under pages/api/page-state with the parameterized query
|
|
86
|
+
const resources = result.options.extractedResources as Record<
|
|
87
|
+
string,
|
|
88
|
+
{ path: string[]; content: string }
|
|
89
|
+
>
|
|
90
|
+
const routeKeys = Object.keys(resources).filter((k) => k.startsWith('pages/api/page-state/'))
|
|
91
|
+
expect(routeKeys).toHaveLength(1)
|
|
92
|
+
const route = resources[routeKeys[0]]
|
|
93
|
+
expect(route.path).toEqual(['pages', 'api', 'page-state'])
|
|
94
|
+
expect(route.content).toContain('user_id = $1')
|
|
95
|
+
expect(route.content).toContain('const { currentUserId } = req.query')
|
|
96
|
+
expect(route.content).not.toContain('{{Current User.id}}')
|
|
97
|
+
|
|
98
|
+
// Client-side effect wired to the signed-in user
|
|
99
|
+
const code = componentCode(result as any)
|
|
100
|
+
expect(code).toContain('const __pageStateCtx = useGlobalContext()')
|
|
101
|
+
expect(code).toContain('useEffect(')
|
|
102
|
+
expect(code).toContain('__pageStateCtx?.currentUser')
|
|
103
|
+
expect(code).toContain('encodeURIComponent(__user?.id ?? "")')
|
|
104
|
+
expect(code).toContain('setCharactersResults(Array.isArray(__data) ? __data : [])')
|
|
105
|
+
expect(code).toContain('/api/page-state/')
|
|
106
|
+
|
|
107
|
+
// Hook + context imports registered
|
|
108
|
+
expect(result.dependencies.useEffect).toBeDefined()
|
|
109
|
+
expect(result.dependencies.useGlobalContext).toBeDefined()
|
|
110
|
+
|
|
111
|
+
// No getStaticProps fetch was emitted for the runtime state
|
|
112
|
+
const gsp = result.chunks.find((c: any) => c.name === 'getStaticProps')
|
|
113
|
+
if (gsp) {
|
|
114
|
+
expect(generator(gsp.content as types.Node).code).not.toContain('charactersResults')
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('shares one fetch effect between states with identical queries and keeps distinct queries apart', async () => {
|
|
119
|
+
const structure = makeStructure({
|
|
120
|
+
charactersResults: makeState(USER_QUERY),
|
|
121
|
+
mirror: makeState(USER_QUERY),
|
|
122
|
+
mainSpotlight: makeState(
|
|
123
|
+
"SELECT id FROM characters WHERE user_id = '{{Current User.id}}' AND is_main = TRUE LIMIT 1"
|
|
124
|
+
),
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const result = await plugin(structure as any)
|
|
128
|
+
|
|
129
|
+
const routeKeys = Object.keys(result.options.extractedResources as object).filter((k) =>
|
|
130
|
+
k.startsWith('pages/api/page-state/')
|
|
131
|
+
)
|
|
132
|
+
expect(routeKeys).toHaveLength(2)
|
|
133
|
+
|
|
134
|
+
const code = componentCode(result as any)
|
|
135
|
+
// Shared group: both setters inside the same effect
|
|
136
|
+
expect(code).toContain('setCharactersResults(')
|
|
137
|
+
expect(code).toContain('setMirror(')
|
|
138
|
+
expect(code).toContain('setMainSpotlight(')
|
|
139
|
+
expect(code.match(/useEffect\(/g)).toHaveLength(2)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('does nothing for such states when the project has no authentication', async () => {
|
|
143
|
+
const structure = makeStructure({ charactersResults: makeState(USER_QUERY) }, { auth: false })
|
|
144
|
+
|
|
145
|
+
const result = await plugin(structure as any)
|
|
146
|
+
|
|
147
|
+
expect(Object.keys(result.options.extractedResources as object)).toHaveLength(0)
|
|
148
|
+
const code = componentCode(result as any)
|
|
149
|
+
expect(code).not.toContain('useEffect(')
|
|
150
|
+
expect(result.dependencies.useEffect).toBeUndefined()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('still skips queries with placeholders other than {{Current User.*}}', async () => {
|
|
154
|
+
const structure = makeStructure({
|
|
155
|
+
detailRows: makeState("SELECT * FROM events WHERE id = '{{Current Page Entity.id}}'"),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const result = await plugin(structure as any)
|
|
159
|
+
|
|
160
|
+
expect(Object.keys(result.options.extractedResources as object)).toHaveLength(0)
|
|
161
|
+
expect(componentCode(result as any)).not.toContain('useEffect(')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('emits a route-param-driven fetch for a {{Current Page Entity.id}} query on a details page', async () => {
|
|
165
|
+
// GuildForge guild-details regression: guildMemberships was bound to an
|
|
166
|
+
// entity-scoped query; the binding was silently dropped so the members
|
|
167
|
+
// list stayed empty in the generated app while the GUI editor (which
|
|
168
|
+
// resolves the binding itself) showed it populated.
|
|
169
|
+
const entityQuery =
|
|
170
|
+
'SELECT m.id, u.name FROM memberships m LEFT JOIN users u ON m.user_id = u.id ' +
|
|
171
|
+
"WHERE m.guild_id = '{{Current Page Entity.id}}' ORDER BY u.name ASC"
|
|
172
|
+
const structure = makeStructure(
|
|
173
|
+
{
|
|
174
|
+
guildMemberships: {
|
|
175
|
+
...makeState(entityQuery),
|
|
176
|
+
dataSourceBinding: { dataSourceId: DS_ID, refPath: [] },
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{ dynamicRouteAttribute: 'id' }
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const result = await plugin(structure as any)
|
|
183
|
+
|
|
184
|
+
const resources = result.options.extractedResources as Record<string, { content: string }>
|
|
185
|
+
const routeKeys = Object.keys(resources).filter((k) => k.startsWith('pages/api/page-state/'))
|
|
186
|
+
expect(routeKeys).toHaveLength(1)
|
|
187
|
+
expect(resources[routeKeys[0]].content).toContain('m.guild_id = $1')
|
|
188
|
+
expect(resources[routeKeys[0]].content).toContain('const { currentPageEntityId } = req.query')
|
|
189
|
+
|
|
190
|
+
const code = componentCode(result as any)
|
|
191
|
+
expect(code).toContain('const __pageStateRouter = useRouter()')
|
|
192
|
+
expect(code).toContain('__pageStateRouter.query?.["id"]')
|
|
193
|
+
expect(code).toContain('encodeURIComponent(__entityId)')
|
|
194
|
+
expect(code).toContain('setGuildMemberships(Array.isArray(__data) ? __data : [])')
|
|
195
|
+
// Entity-only query must not require auth context
|
|
196
|
+
expect(code).not.toContain('__pageStateCtx')
|
|
197
|
+
expect(result.dependencies.useRouter).toBeDefined()
|
|
198
|
+
expect(result.dependencies.useGlobalContext).toBeUndefined()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('works without authentication when the query only uses {{Current Page Entity.id}}', async () => {
|
|
202
|
+
const entityQuery = "SELECT * FROM memberships WHERE guild_id = '{{Current Page Entity.id}}'"
|
|
203
|
+
const structure = makeStructure(
|
|
204
|
+
{ members: makeState(entityQuery) },
|
|
205
|
+
{ auth: false, dynamicRouteAttribute: 'id' }
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
const result = await plugin(structure as any)
|
|
209
|
+
expect(
|
|
210
|
+
Object.keys(result.options.extractedResources as object).filter((k) =>
|
|
211
|
+
k.startsWith('pages/api/page-state/')
|
|
212
|
+
)
|
|
213
|
+
).toHaveLength(1)
|
|
214
|
+
expect(componentCode(result as any)).toContain('useEffect(')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('skips a {{Current Page Entity.id}} query on a non-details page (no route param)', async () => {
|
|
218
|
+
const entityQuery = "SELECT * FROM memberships WHERE guild_id = '{{Current Page Entity.id}}'"
|
|
219
|
+
const structure = makeStructure({ members: makeState(entityQuery) })
|
|
220
|
+
|
|
221
|
+
const result = await plugin(structure as any)
|
|
222
|
+
expect(Object.keys(result.options.extractedResources as object)).toHaveLength(0)
|
|
223
|
+
expect(componentCode(result as any)).not.toContain('useEffect(')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('combines user and entity tokens in one fetch with both guards', async () => {
|
|
227
|
+
const mixedQuery =
|
|
228
|
+
"SELECT * FROM memberships WHERE guild_id = '{{Current Page Entity.id}}' " +
|
|
229
|
+
"AND user_id = '{{Current User.id}}'"
|
|
230
|
+
const structure = makeStructure(
|
|
231
|
+
{ myMembership: makeState(mixedQuery) },
|
|
232
|
+
{ dynamicRouteAttribute: 'id' }
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
const result = await plugin(structure as any)
|
|
236
|
+
|
|
237
|
+
const resources = result.options.extractedResources as Record<string, { content: string }>
|
|
238
|
+
const routeKey = Object.keys(resources).find((k) => k.startsWith('pages/api/page-state/'))!
|
|
239
|
+
expect(resources[routeKey].content).toContain('guild_id = $1')
|
|
240
|
+
expect(resources[routeKey].content).toContain('user_id = $2')
|
|
241
|
+
expect(resources[routeKey].content).toContain('currentPageEntityId, currentUserId')
|
|
242
|
+
|
|
243
|
+
const code = componentCode(result as any)
|
|
244
|
+
expect(code).toContain('const __pageStateCtx = useGlobalContext()')
|
|
245
|
+
expect(code).toContain('const __pageStateRouter = useRouter()')
|
|
246
|
+
expect(code).toContain('if (!__user) return')
|
|
247
|
+
expect(code).toContain('if (!__entityId) return')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('keeps build-time states in getStaticProps while runtime states get effects', async () => {
|
|
251
|
+
const structure = makeStructure({
|
|
252
|
+
allGuilds: makeState('SELECT * FROM guilds ORDER BY name ASC'),
|
|
253
|
+
myCharacters: makeState(USER_QUERY),
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
const result = await plugin(structure as any)
|
|
257
|
+
|
|
258
|
+
const gsp = result.chunks.find((c: any) => c.name === 'getStaticProps')
|
|
259
|
+
expect(gsp).toBeDefined()
|
|
260
|
+
const gspCode = generator(gsp.content as types.Node).code
|
|
261
|
+
expect(gspCode).toContain('allGuilds')
|
|
262
|
+
expect(gspCode).not.toContain('myCharacters')
|
|
263
|
+
|
|
264
|
+
const code = componentCode(result as any)
|
|
265
|
+
expect(code).toContain('setMyCharacters(')
|
|
266
|
+
expect(code).not.toContain('setAllGuilds(')
|
|
267
|
+
})
|
|
268
|
+
})
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"state-data-source-plugin.d.ts","sourceRoot":"","sources":["../../src/state-data-source-plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,sBAAsB,EAQvB,MAAM,4BAA4B,CAAA;
|
|
1
|
+
{"version":3,"file":"state-data-source-plugin.d.ts","sourceRoot":"","sources":["../../src/state-data-source-plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,sBAAsB,EAQvB,MAAM,4BAA4B,CAAA;AA0/BnC,eAAO,MAAM,2BAA2B,EAAE,sBAAsB,CAAC,EAAE,CAkKlE,CAAA"}
|