Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,34 @@ function resolveContextRef(ref, context) {
let result = nodeOutput;
const startIdx = (ref.path.length > 0 && ref.path[0] === ref.nodeId) ? 1 : 0;
for (let i = startIdx; i < ref.path.length; i++) {
if (result === undefined || result === null) return undefined;
if (result === undefined || result === null) { result = undefined; break; }
result = result[ref.path[i]];
}
// Fallback for form-submit trigger bindings authored without the formData
// level: the trigger's runtime output nests submitted fields under formData
// ({ formData: { field: value }, formId, ... }), but the editor/AI has
// historically emitted paths that read the field directly off the trigger
// ([triggerId, 'class']). When the direct walk misses AND the node output
// carries a formData object that holds the first path segment, resolve
// through it instead of returning undefined — otherwise data-create-item
// INSERTs NULL for the column and violates not-null constraints. The
// hasOwnProperty gate keeps this from inventing values for genuinely
// missing keys.
if (result === undefined && startIdx < ref.path.length) {
const formDataBag = nodeOutput.formData;
if (
formDataBag &&
typeof formDataBag === 'object' &&
Object.prototype.hasOwnProperty.call(formDataBag, ref.path[startIdx])
) {
let fallback = formDataBag;
for (let j = startIdx; j < ref.path.length; j++) {
if (fallback === undefined || fallback === null) return undefined;
fallback = fallback[ref.path[j]];
}
return fallback;
}
}
return result;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,20 @@ export const createNextWorkflowPlugin: ComponentPluginFactory<WorkflowPluginConf
if (elementId) {
elementTriggers.push({ workflow: wf, elementId, reactProp, triggerConfig })
} else {
lifecycleWorkflows.push(wf)
// An element-event trigger (click/change/submit/…) with NO bound
// element cannot fire — the author detached it or never wired it.
// It used to fall through to lifecycleWorkflows, where the trigger
// switch has no case for element events: the workflow never
// executed but its full __wfConfig_* blob still shipped in every
// page bundle (element scope with no selectedPages matches ALL
// pages). Skip it entirely — an unbound workflow stays a draft in
// the project without leaking into the generated app.
// eslint-disable-next-line no-console
console.warn(
`[workflow-plugin] Skipping workflow "${wf.name || wf.id}" on "${uidl.name}" — its ${
trigger.type
} trigger has no bound element.`
)
}
} else {
lifecycleWorkflows.push(wf)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import generator from '@babel/generator'
import * as types from '@babel/types'
import { createStateDataSourcePlugin } from '../src/state-data-source-plugin'

// Regression guard for the "Characters page renders empty lists" defect
// (GuildForge run, examples/uidl-samples/project.json): page states bound to a
// data source with a `{{Current User.*}}` query were silently dropped — no
// getStaticProps fetch is possible (the placeholder only resolves at runtime)
// and nothing else populated them, so every list bound to such a state stayed
// empty forever. The plugin must instead emit an API route + a client-side
// useEffect fetch keyed on the signed-in user.

const DS_ID = '8e4bd2e9-1ceb-41ee-91ea-6f2aca94076e'

const USER_QUERY =
"SELECT id, user_id, name FROM characters WHERE (user_id = '{{Current User.id}}' OR user_id IS NULL) ORDER BY name ASC LIMIT 20"

const makeState = (query?: string) => ({
type: 'array',
defaultValue: [] as unknown[],
dataSourceBinding: {
dataSourceId: DS_ID,
refPath: ['characters'],
},
...(query ? { query } : {}),
})

const makeJsxComponentChunk = () => ({
type: 'AST',
fileType: 'js',
name: 'jsx-component',
linkAfter: [] as string[],
content: types.variableDeclaration('const', [
types.variableDeclarator(
types.identifier('Characters'),
types.arrowFunctionExpression(
[],
types.blockStatement([types.returnStatement(types.nullLiteral())])
)
),
]),
})

const makeStructure = (
stateDefinitions: Record<string, unknown>,
{ auth = true, dynamicRouteAttribute }: { auth?: boolean; dynamicRouteAttribute?: string } = {}
) => ({
uidl: {
name: 'Characters',
stateDefinitions,
node: { type: 'element', content: { elementType: 'container' } },
outputOptions: {
folderPath: [] as string[],
...(dynamicRouteAttribute && { dynamicRouteAttribute }),
},
},
chunks: [makeJsxComponentChunk()] as any[],
dependencies: {} as Record<string, unknown>,
options: {
dataSources: {
[DS_ID]: {
id: DS_ID,
type: 'teleport',
config: {},
},
},
extractedResources: {} as Record<string, unknown>,
...(auth ? { auth: { provider: 'teleport' } } : {}),
},
})

const componentCode = (structure: { chunks: any[] }): string => {
const chunk = structure.chunks.find((c) => c.name === 'jsx-component')
return generator(chunk.content as types.Node).code
}

describe('state-data-source-plugin runtime {{Current User.*}} fetch', () => {
const plugin = createStateDataSourcePlugin()

it('emits an API route + useEffect fetch for a Current-User-bound state', async () => {
const structure = makeStructure({ charactersResults: makeState(USER_QUERY) })

const result = await plugin(structure as any)

// API route extracted under pages/api/page-state with the parameterized query
const resources = result.options.extractedResources as Record<
string,
{ path: string[]; content: string }
>
const routeKeys = Object.keys(resources).filter((k) => k.startsWith('pages/api/page-state/'))
expect(routeKeys).toHaveLength(1)
const route = resources[routeKeys[0]]
expect(route.path).toEqual(['pages', 'api', 'page-state'])
expect(route.content).toContain('user_id = $1')
expect(route.content).toContain('const { currentUserId } = req.query')
expect(route.content).not.toContain('{{Current User.id}}')

// Client-side effect wired to the signed-in user
const code = componentCode(result as any)
expect(code).toContain('const __pageStateCtx = useGlobalContext()')
expect(code).toContain('useEffect(')
expect(code).toContain('__pageStateCtx?.currentUser')
expect(code).toContain('encodeURIComponent(__user?.id ?? "")')
expect(code).toContain('setCharactersResults(Array.isArray(__data) ? __data : [])')
expect(code).toContain('/api/page-state/')

// Hook + context imports registered
expect(result.dependencies.useEffect).toBeDefined()
expect(result.dependencies.useGlobalContext).toBeDefined()

// No getStaticProps fetch was emitted for the runtime state
const gsp = result.chunks.find((c: any) => c.name === 'getStaticProps')
if (gsp) {
expect(generator(gsp.content as types.Node).code).not.toContain('charactersResults')
}
})

it('shares one fetch effect between states with identical queries and keeps distinct queries apart', async () => {
const structure = makeStructure({
charactersResults: makeState(USER_QUERY),
mirror: makeState(USER_QUERY),
mainSpotlight: makeState(
"SELECT id FROM characters WHERE user_id = '{{Current User.id}}' AND is_main = TRUE LIMIT 1"
),
})

const result = await plugin(structure as any)

const routeKeys = Object.keys(result.options.extractedResources as object).filter((k) =>
k.startsWith('pages/api/page-state/')
)
expect(routeKeys).toHaveLength(2)

const code = componentCode(result as any)
// Shared group: both setters inside the same effect
expect(code).toContain('setCharactersResults(')
expect(code).toContain('setMirror(')
expect(code).toContain('setMainSpotlight(')
expect(code.match(/useEffect\(/g)).toHaveLength(2)
})

it('does nothing for such states when the project has no authentication', async () => {
const structure = makeStructure({ charactersResults: makeState(USER_QUERY) }, { auth: false })

const result = await plugin(structure as any)

expect(Object.keys(result.options.extractedResources as object)).toHaveLength(0)
const code = componentCode(result as any)
expect(code).not.toContain('useEffect(')
expect(result.dependencies.useEffect).toBeUndefined()
})

it('still skips queries with placeholders other than {{Current User.*}}', async () => {
const structure = makeStructure({
detailRows: makeState("SELECT * FROM events WHERE id = '{{Current Page Entity.id}}'"),
})

const result = await plugin(structure as any)

expect(Object.keys(result.options.extractedResources as object)).toHaveLength(0)
expect(componentCode(result as any)).not.toContain('useEffect(')
})

it('emits a route-param-driven fetch for a {{Current Page Entity.id}} query on a details page', async () => {
// GuildForge guild-details regression: guildMemberships was bound to an
// entity-scoped query; the binding was silently dropped so the members
// list stayed empty in the generated app while the GUI editor (which
// resolves the binding itself) showed it populated.
const entityQuery =
'SELECT m.id, u.name FROM memberships m LEFT JOIN users u ON m.user_id = u.id ' +
"WHERE m.guild_id = '{{Current Page Entity.id}}' ORDER BY u.name ASC"
const structure = makeStructure(
{
guildMemberships: {
...makeState(entityQuery),
dataSourceBinding: { dataSourceId: DS_ID, refPath: [] },
},
},
{ dynamicRouteAttribute: 'id' }
)

const result = await plugin(structure as any)

const resources = result.options.extractedResources as Record<string, { content: string }>
const routeKeys = Object.keys(resources).filter((k) => k.startsWith('pages/api/page-state/'))
expect(routeKeys).toHaveLength(1)
expect(resources[routeKeys[0]].content).toContain('m.guild_id = $1')
expect(resources[routeKeys[0]].content).toContain('const { currentPageEntityId } = req.query')

const code = componentCode(result as any)
expect(code).toContain('const __pageStateRouter = useRouter()')
expect(code).toContain('__pageStateRouter.query?.["id"]')
expect(code).toContain('encodeURIComponent(__entityId)')
expect(code).toContain('setGuildMemberships(Array.isArray(__data) ? __data : [])')
// Entity-only query must not require auth context
expect(code).not.toContain('__pageStateCtx')
expect(result.dependencies.useRouter).toBeDefined()
expect(result.dependencies.useGlobalContext).toBeUndefined()
})

it('works without authentication when the query only uses {{Current Page Entity.id}}', async () => {
const entityQuery = "SELECT * FROM memberships WHERE guild_id = '{{Current Page Entity.id}}'"
const structure = makeStructure(
{ members: makeState(entityQuery) },
{ auth: false, dynamicRouteAttribute: 'id' }
)

const result = await plugin(structure as any)
expect(
Object.keys(result.options.extractedResources as object).filter((k) =>
k.startsWith('pages/api/page-state/')
)
).toHaveLength(1)
expect(componentCode(result as any)).toContain('useEffect(')
})

it('skips a {{Current Page Entity.id}} query on a non-details page (no route param)', async () => {
const entityQuery = "SELECT * FROM memberships WHERE guild_id = '{{Current Page Entity.id}}'"
const structure = makeStructure({ members: makeState(entityQuery) })

const result = await plugin(structure as any)
expect(Object.keys(result.options.extractedResources as object)).toHaveLength(0)
expect(componentCode(result as any)).not.toContain('useEffect(')
})

it('combines user and entity tokens in one fetch with both guards', async () => {
const mixedQuery =
"SELECT * FROM memberships WHERE guild_id = '{{Current Page Entity.id}}' " +
"AND user_id = '{{Current User.id}}'"
const structure = makeStructure(
{ myMembership: makeState(mixedQuery) },
{ dynamicRouteAttribute: 'id' }
)

const result = await plugin(structure as any)

const resources = result.options.extractedResources as Record<string, { content: string }>
const routeKey = Object.keys(resources).find((k) => k.startsWith('pages/api/page-state/'))!
expect(resources[routeKey].content).toContain('guild_id = $1')
expect(resources[routeKey].content).toContain('user_id = $2')
expect(resources[routeKey].content).toContain('currentPageEntityId, currentUserId')

const code = componentCode(result as any)
expect(code).toContain('const __pageStateCtx = useGlobalContext()')
expect(code).toContain('const __pageStateRouter = useRouter()')
expect(code).toContain('if (!__user) return')
expect(code).toContain('if (!__entityId) return')
})

it('keeps build-time states in getStaticProps while runtime states get effects', async () => {
const structure = makeStructure({
allGuilds: makeState('SELECT * FROM guilds ORDER BY name ASC'),
myCharacters: makeState(USER_QUERY),
})

const result = await plugin(structure as any)

const gsp = result.chunks.find((c: any) => c.name === 'getStaticProps')
expect(gsp).toBeDefined()
const gspCode = generator(gsp.content as types.Node).code
expect(gspCode).toContain('allGuilds')
expect(gspCode).not.toContain('myCharacters')

const code = componentCode(result as any)
expect(code).toContain('setMyCharacters(')
expect(code).not.toContain('setAllGuilds(')
})
})
Loading
Loading