@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.
@@ -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;AAohBnC,eAAO,MAAM,2BAA2B,EAAE,sBAAsB,CAAC,EAAE,CAyJlE,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"}