@teleporthq/teleport-plugin-next-workflows 0.43.41 → 0.43.44
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__/cart-availability-add-to-cart.test.ts +47 -0
- package/__tests__/component-scope-trigger-routing.test.ts +130 -0
- package/__tests__/cron-route-loop-templateparams.test.ts +144 -0
- package/__tests__/data-api-on-conflict-do-nothing.test.ts +35 -0
- package/__tests__/email-body-context-resolution.test.ts +126 -0
- package/__tests__/page-loaded-scoping-skip.test.ts +97 -0
- package/__tests__/raw-sql-param-binding.test.ts +138 -0
- package/__tests__/route-changed-trigger.test.ts +106 -0
- package/__tests__/state-update-missing-setter-warn.test.ts +180 -0
- package/dist/cjs/account-delete-route-generator.d.ts +13 -0
- package/dist/cjs/account-delete-route-generator.d.ts.map +1 -0
- package/dist/cjs/account-delete-route-generator.js +247 -0
- package/dist/cjs/account-delete-route-generator.js.map +1 -0
- package/dist/cjs/api-route-generator.d.ts +1 -1
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +29 -33
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/auth-generator.d.ts +9 -1
- package/dist/cjs/auth-generator.d.ts.map +1 -1
- package/dist/cjs/auth-generator.js +53 -2
- package/dist/cjs/auth-generator.js.map +1 -1
- package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
- package/dist/cjs/data-api-route-generator.js +11 -2
- package/dist/cjs/data-api-route-generator.js.map +1 -1
- package/dist/cjs/ecommerce/cart-availability.d.ts.map +1 -1
- package/dist/cjs/ecommerce/cart-availability.js +49 -2
- package/dist/cjs/ecommerce/cart-availability.js.map +1 -1
- package/dist/cjs/ecommerce/stock-decrement.d.ts +1 -1
- package/dist/cjs/ecommerce/stock-decrement.d.ts.map +1 -1
- package/dist/cjs/ecommerce/stock-decrement.js +48 -10
- package/dist/cjs/ecommerce/stock-decrement.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +62 -0
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/graph-utils.d.ts.map +1 -1
- package/dist/cjs/graph-utils.js +1 -0
- package/dist/cjs/graph-utils.js.map +1 -1
- package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/cjs/invoice/api-routes-code.js +2 -0
- package/dist/cjs/invoice/api-routes-code.js.map +1 -1
- package/dist/cjs/invoice/data-access-code.d.ts.map +1 -1
- package/dist/cjs/invoice/data-access-code.js +2 -0
- package/dist/cjs/invoice/data-access-code.js.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.js +22 -2
- package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
- package/dist/cjs/nodes/account/account-delete-current.d.ts +3 -0
- package/dist/cjs/nodes/account/account-delete-current.d.ts.map +1 -0
- package/dist/cjs/nodes/account/account-delete-current.js +79 -0
- package/dist/cjs/nodes/account/account-delete-current.js.map +1 -0
- package/dist/cjs/nodes/account/account-signup.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-signup.js +20 -1
- package/dist/cjs/nodes/account/account-signup.js.map +1 -1
- package/dist/cjs/nodes/data/data-create-item.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-create-item.js +7 -0
- package/dist/cjs/nodes/data/data-create-item.js.map +1 -1
- package/dist/cjs/nodes/index.d.ts.map +1 -1
- package/dist/cjs/nodes/index.js +2 -1
- package/dist/cjs/nodes/index.js.map +1 -1
- package/dist/cjs/nodes/payment/payment-charge-user.d.ts.map +1 -1
- package/dist/cjs/nodes/payment/payment-charge-user.js +9 -1
- package/dist/cjs/nodes/payment/payment-charge-user.js.map +1 -1
- package/dist/cjs/pg-client-code.d.ts +11 -0
- package/dist/cjs/pg-client-code.d.ts.map +1 -0
- package/dist/cjs/pg-client-code.js +75 -0
- package/dist/cjs/pg-client-code.js.map +1 -0
- package/dist/cjs/raw-sql-param-binding.d.ts +82 -0
- package/dist/cjs/raw-sql-param-binding.d.ts.map +1 -0
- package/dist/cjs/raw-sql-param-binding.js +321 -0
- package/dist/cjs/raw-sql-param-binding.js.map +1 -0
- package/dist/cjs/transactional-email-code.d.ts +6 -0
- package/dist/cjs/transactional-email-code.d.ts.map +1 -0
- package/dist/cjs/transactional-email-code.js +112 -0
- package/dist/cjs/transactional-email-code.js.map +1 -0
- package/dist/cjs/trigger-generator.d.ts.map +1 -1
- package/dist/cjs/trigger-generator.js +29 -0
- package/dist/cjs/trigger-generator.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/workflow-component-plugin.js +60 -2
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-project-plugin.js +188 -28
- package/dist/cjs/workflow-project-plugin.js.map +1 -1
- package/dist/esm/account-delete-route-generator.d.ts +13 -0
- package/dist/esm/account-delete-route-generator.d.ts.map +1 -0
- package/dist/esm/account-delete-route-generator.js +243 -0
- package/dist/esm/account-delete-route-generator.js.map +1 -0
- package/dist/esm/api-route-generator.d.ts +1 -1
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +29 -33
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/auth-generator.d.ts +9 -1
- package/dist/esm/auth-generator.d.ts.map +1 -1
- package/dist/esm/auth-generator.js +53 -2
- package/dist/esm/auth-generator.js.map +1 -1
- package/dist/esm/data-api-route-generator.d.ts.map +1 -1
- package/dist/esm/data-api-route-generator.js +11 -2
- package/dist/esm/data-api-route-generator.js.map +1 -1
- package/dist/esm/ecommerce/cart-availability.d.ts.map +1 -1
- package/dist/esm/ecommerce/cart-availability.js +49 -2
- package/dist/esm/ecommerce/cart-availability.js.map +1 -1
- package/dist/esm/ecommerce/stock-decrement.d.ts +1 -1
- package/dist/esm/ecommerce/stock-decrement.d.ts.map +1 -1
- package/dist/esm/ecommerce/stock-decrement.js +48 -10
- package/dist/esm/ecommerce/stock-decrement.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +62 -0
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/graph-utils.d.ts.map +1 -1
- package/dist/esm/graph-utils.js +1 -0
- package/dist/esm/graph-utils.js.map +1 -1
- package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/esm/invoice/api-routes-code.js +2 -0
- package/dist/esm/invoice/api-routes-code.js.map +1 -1
- package/dist/esm/invoice/data-access-code.d.ts.map +1 -1
- package/dist/esm/invoice/data-access-code.js +2 -0
- package/dist/esm/invoice/data-access-code.js.map +1 -1
- package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/esm/invoice/invoice-html-code.js +22 -2
- package/dist/esm/invoice/invoice-html-code.js.map +1 -1
- package/dist/esm/nodes/account/account-delete-current.d.ts +3 -0
- package/dist/esm/nodes/account/account-delete-current.d.ts.map +1 -0
- package/dist/esm/nodes/account/account-delete-current.js +76 -0
- package/dist/esm/nodes/account/account-delete-current.js.map +1 -0
- package/dist/esm/nodes/account/account-signup.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-signup.js +20 -1
- package/dist/esm/nodes/account/account-signup.js.map +1 -1
- package/dist/esm/nodes/data/data-create-item.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-create-item.js +7 -0
- package/dist/esm/nodes/data/data-create-item.js.map +1 -1
- package/dist/esm/nodes/index.d.ts.map +1 -1
- package/dist/esm/nodes/index.js +2 -1
- package/dist/esm/nodes/index.js.map +1 -1
- package/dist/esm/nodes/payment/payment-charge-user.d.ts.map +1 -1
- package/dist/esm/nodes/payment/payment-charge-user.js +9 -1
- package/dist/esm/nodes/payment/payment-charge-user.js.map +1 -1
- package/dist/esm/pg-client-code.d.ts +11 -0
- package/dist/esm/pg-client-code.d.ts.map +1 -0
- package/dist/esm/pg-client-code.js +71 -0
- package/dist/esm/pg-client-code.js.map +1 -0
- package/dist/esm/raw-sql-param-binding.d.ts +82 -0
- package/dist/esm/raw-sql-param-binding.d.ts.map +1 -0
- package/dist/esm/raw-sql-param-binding.js +314 -0
- package/dist/esm/raw-sql-param-binding.js.map +1 -0
- package/dist/esm/transactional-email-code.d.ts +6 -0
- package/dist/esm/transactional-email-code.d.ts.map +1 -0
- package/dist/esm/transactional-email-code.js +106 -0
- package/dist/esm/transactional-email-code.js.map +1 -0
- package/dist/esm/trigger-generator.d.ts.map +1 -1
- package/dist/esm/trigger-generator.js +29 -0
- package/dist/esm/trigger-generator.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/workflow-component-plugin.js +60 -2
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-project-plugin.js +188 -28
- package/dist/esm/workflow-project-plugin.js.map +1 -1
- package/package.json +4 -4
- package/src/account-delete-route-generator.ts +269 -0
- package/src/api-route-generator.ts +35 -33
- package/src/auth-generator.ts +77 -2
- package/src/data-api-route-generator.ts +11 -2
- package/src/ecommerce/cart-availability.ts +49 -2
- package/src/ecommerce/stock-decrement.ts +48 -10
- package/src/executor-generator.ts +62 -0
- package/src/graph-utils.ts +1 -0
- package/src/invoice/api-routes-code.ts +2 -0
- package/src/invoice/data-access-code.ts +2 -0
- package/src/invoice/invoice-html-code.ts +22 -2
- package/src/nodes/account/account-delete-current.ts +86 -0
- package/src/nodes/account/account-signup.ts +20 -1
- package/src/nodes/data/data-create-item.ts +7 -0
- package/src/nodes/index.ts +2 -0
- package/src/nodes/payment/payment-charge-user.ts +9 -1
- package/src/pg-client-code.ts +70 -0
- package/src/raw-sql-param-binding.ts +406 -0
- package/src/transactional-email-code.ts +112 -0
- package/src/trigger-generator.ts +35 -0
- package/src/workflow-component-plugin.ts +71 -2
- package/src/workflow-project-plugin.ts +221 -26
|
@@ -352,6 +352,53 @@ describe('buildAddToCartLimitCheck — runtime semantics', () => {
|
|
|
352
352
|
expect(result.existingItemId).toBe('cart-1')
|
|
353
353
|
})
|
|
354
354
|
|
|
355
|
+
// Variant-aware de-dup: a cart line is identified by product AND variant.
|
|
356
|
+
const buildParamsV = (
|
|
357
|
+
productId: string,
|
|
358
|
+
variantId: string | null,
|
|
359
|
+
quantity: number,
|
|
360
|
+
settings: any,
|
|
361
|
+
product: any,
|
|
362
|
+
cartItems: any[]
|
|
363
|
+
): any[] => {
|
|
364
|
+
const arr: any[] = new Array(14).fill(null)
|
|
365
|
+
arr[0] = { productId, variantId, quantity }
|
|
366
|
+
arr[2] = settings
|
|
367
|
+
arr[6] = product ? { found: true, product } : { found: false, product: null }
|
|
368
|
+
arr[13] = cartItems
|
|
369
|
+
return arr
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
it('same product, DIFFERENT variant already in cart → treated as a NEW line (no increment)', () => {
|
|
373
|
+
const params = buildParamsV('a', 'a__blue', 1, baseSettings(), { name: 'Tee', quantity: 5 }, [
|
|
374
|
+
{ id: 'cart-red', productId: 'a', variantId: 'a__red', quantity: 1 },
|
|
375
|
+
])
|
|
376
|
+
const result = fn({}, params)
|
|
377
|
+
expect(result.canAdd).toBe(true)
|
|
378
|
+
expect(result.shouldIncrement).toBe(false)
|
|
379
|
+
expect(result.existingItemId).toBe(null)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('same product, SAME variant already in cart → increments THAT line', () => {
|
|
383
|
+
const params = buildParamsV('a', 'a__red', 1, baseSettings(), { name: 'Tee', quantity: 5 }, [
|
|
384
|
+
{ id: 'cart-red', productId: 'a', variantId: 'a__red', quantity: 1 },
|
|
385
|
+
{ id: 'cart-blue', productId: 'a', variantId: 'a__blue', quantity: 1 },
|
|
386
|
+
])
|
|
387
|
+
const result = fn({}, params)
|
|
388
|
+
expect(result.canAdd).toBe(true)
|
|
389
|
+
expect(result.shouldIncrement).toBe(true)
|
|
390
|
+
expect(result.existingItemId).toBe('cart-red')
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('flat product (no variantId) matches the flat cart line', () => {
|
|
394
|
+
const params = buildParamsV('a', null, 1, baseSettings(), { name: 'Mug', quantity: 5 }, [
|
|
395
|
+
{ id: 'cart-flat', productId: 'a', quantity: 2 },
|
|
396
|
+
])
|
|
397
|
+
const result = fn({}, params)
|
|
398
|
+
expect(result.shouldIncrement).toBe(true)
|
|
399
|
+
expect(result.existingItemId).toBe('cart-flat')
|
|
400
|
+
})
|
|
401
|
+
|
|
355
402
|
it('new item would exceed maxQuantityPerProduct → canAdd:false, names product', () => {
|
|
356
403
|
const params = buildParams(
|
|
357
404
|
'a',
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Routing guard for the `'component'` trigger scope: an `event-component-loaded`
|
|
2
|
+
// workflow must be generated ONLY into the component it is bound to (matched by
|
|
3
|
+
// the emitted component name, with a normalized-compare and componentId
|
|
4
|
+
// fallback) — never into pages, never into unrelated components. This is the
|
|
5
|
+
// fix for the original bug where a component workflow authored with page scope
|
|
6
|
+
// was stripped from the component and silently fanned out to every page.
|
|
7
|
+
|
|
8
|
+
import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
|
|
9
|
+
|
|
10
|
+
const componentLoadedWorkflow = (config: Record<string, unknown>) => ({
|
|
11
|
+
id: 'wf-comp-loaded',
|
|
12
|
+
name: 'Auto-open Sidebar Group',
|
|
13
|
+
trigger: {
|
|
14
|
+
type: 'event-component-loaded',
|
|
15
|
+
nodeId: 'trigger-component-loaded',
|
|
16
|
+
scope: 'component',
|
|
17
|
+
config,
|
|
18
|
+
},
|
|
19
|
+
nodes: [
|
|
20
|
+
{
|
|
21
|
+
id: 'update-1',
|
|
22
|
+
type: 'state-update-local-state',
|
|
23
|
+
config: { property: 'sidebarGroupOpen', value: 'guilds' },
|
|
24
|
+
stepNumber: 1,
|
|
25
|
+
label: 'Set group',
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
edges: [{ id: 'e1', source: 'trigger-component-loaded', target: 'update-1' }],
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const jsxComponentChunk = () => ({
|
|
32
|
+
type: 'chunk-type-ast',
|
|
33
|
+
name: 'jsx-component',
|
|
34
|
+
content: {
|
|
35
|
+
type: 'VariableDeclaration',
|
|
36
|
+
declarations: [
|
|
37
|
+
{
|
|
38
|
+
type: 'VariableDeclarator',
|
|
39
|
+
init: {
|
|
40
|
+
type: 'ArrowFunctionExpression',
|
|
41
|
+
body: {
|
|
42
|
+
type: 'BlockStatement',
|
|
43
|
+
body: [{ type: 'ReturnStatement', argument: null }],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const buildStructure = (uidl: Record<string, unknown>, workflow: any): any => ({
|
|
52
|
+
uidl: {
|
|
53
|
+
node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
|
|
54
|
+
stateDefinitions: { sidebarGroupOpen: { type: 'string', defaultValue: '' } },
|
|
55
|
+
...uidl,
|
|
56
|
+
},
|
|
57
|
+
chunks: [jsxComponentChunk()],
|
|
58
|
+
options: {
|
|
59
|
+
workflows: { workflows: { [workflow.id]: workflow }, customNodes: {} },
|
|
60
|
+
},
|
|
61
|
+
dependencies: {},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const getWorkflowModule = async (
|
|
65
|
+
uidl: Record<string, unknown>,
|
|
66
|
+
workflow: any,
|
|
67
|
+
isPage: boolean
|
|
68
|
+
): Promise<string | null> => {
|
|
69
|
+
const plugin = createNextWorkflowPlugin({ isPage })
|
|
70
|
+
const structure = buildStructure(uidl, workflow)
|
|
71
|
+
await plugin(structure as any)
|
|
72
|
+
const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module')
|
|
73
|
+
return moduleChunk ? String(moduleChunk.content) : null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('component-scope trigger routing', () => {
|
|
77
|
+
it('generates the workflow into the component matching componentName', async () => {
|
|
78
|
+
const code = await getWorkflowModule(
|
|
79
|
+
{ name: 'Navigation' },
|
|
80
|
+
componentLoadedWorkflow({ componentId: 'TQ_QmitTH632i', componentName: 'Navigation' }),
|
|
81
|
+
false
|
|
82
|
+
)
|
|
83
|
+
expect(code).not.toBeNull()
|
|
84
|
+
expect(code).toContain('Component loaded')
|
|
85
|
+
expect(code).toContain("componentName: 'Navigation'")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('matches case/separator-insensitively (generator may re-case the name)', async () => {
|
|
89
|
+
const code = await getWorkflowModule(
|
|
90
|
+
{ name: 'CookieConsent' },
|
|
91
|
+
componentLoadedWorkflow({ componentName: 'Cookie Consent' }),
|
|
92
|
+
false
|
|
93
|
+
)
|
|
94
|
+
expect(code).not.toBeNull()
|
|
95
|
+
expect(code).toContain('Component loaded')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('falls back to componentId when the name does not match', async () => {
|
|
99
|
+
const code = await getWorkflowModule(
|
|
100
|
+
{ name: 'Navigation', outputOptions: { fileName: 'navigation' } },
|
|
101
|
+
componentLoadedWorkflow({ componentId: 'navigation', componentName: 'Renamed Later' }),
|
|
102
|
+
false
|
|
103
|
+
)
|
|
104
|
+
expect(code).not.toBeNull()
|
|
105
|
+
expect(code).toContain('Component loaded')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('does NOT generate into an unrelated component', async () => {
|
|
109
|
+
const code = await getWorkflowModule(
|
|
110
|
+
{ name: 'Footer' },
|
|
111
|
+
componentLoadedWorkflow({ componentId: 'TQ_QmitTH632i', componentName: 'Navigation' }),
|
|
112
|
+
false
|
|
113
|
+
)
|
|
114
|
+
expect(code).toBeNull()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('does NOT generate into pages', async () => {
|
|
118
|
+
const code = await getWorkflowModule(
|
|
119
|
+
{ name: 'Navigation', outputOptions: { pageId: 'page-1', fileName: 'home' } },
|
|
120
|
+
componentLoadedWorkflow({ componentId: 'TQ_QmitTH632i', componentName: 'Navigation' }),
|
|
121
|
+
true
|
|
122
|
+
)
|
|
123
|
+
expect(code).toBeNull()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('matches nothing when both componentId and componentName are missing', async () => {
|
|
127
|
+
const code = await getWorkflowModule({ name: 'Navigation' }, componentLoadedWorkflow({}), false)
|
|
128
|
+
expect(code).toBeNull()
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Regression test for the abandoned-cart reminder feature's codegen dependency:
|
|
2
|
+
// a scheduled (cron) workflow must generate a route that (a) executes a
|
|
3
|
+
// `general-loop` body per row and (b) merges an email node's `templateParams`
|
|
4
|
+
// into its component body — the cron route previously used a naive inline loop
|
|
5
|
+
// that did neither, so a per-cart reminder email would ship raw {{token}} merge
|
|
6
|
+
// fields and its loop body would never run.
|
|
7
|
+
import { generateCronAPIRoute } from '../src/api-route-generator'
|
|
8
|
+
|
|
9
|
+
const TRIGGER_ID = 'cron-1'
|
|
10
|
+
const SCAN_ID = 'scan-1'
|
|
11
|
+
const LOOP_ID = 'loop-1'
|
|
12
|
+
const SHIM_ID = 'shim-1'
|
|
13
|
+
const CREATE_ID = 'create-1'
|
|
14
|
+
const EMAIL_ID = 'email-1'
|
|
15
|
+
|
|
16
|
+
const buildCronWorkflow = (): any => ({
|
|
17
|
+
id: 'wf-abandoned-cart',
|
|
18
|
+
name: 'Abandoned Cart Reminder',
|
|
19
|
+
trigger: {
|
|
20
|
+
type: 'event-cron-triggered',
|
|
21
|
+
nodeId: TRIGGER_ID,
|
|
22
|
+
scope: 'global',
|
|
23
|
+
config: { schedule: '0 * * * *', timezone: 'UTC', urlPath: 'cron_abandoned' },
|
|
24
|
+
},
|
|
25
|
+
nodes: [
|
|
26
|
+
{
|
|
27
|
+
id: SCAN_ID,
|
|
28
|
+
type: 'data-raw-query',
|
|
29
|
+
config: { dataSourceId: 'ds-1', query: 'SELECT 1', params: ['2'] },
|
|
30
|
+
executionEnv: 'server',
|
|
31
|
+
stepNumber: 1,
|
|
32
|
+
label: 'Scan Abandoned Carts',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: LOOP_ID,
|
|
36
|
+
type: 'general-loop',
|
|
37
|
+
config: {
|
|
38
|
+
loopType: 'forEach',
|
|
39
|
+
collection: { type: 'workflowContext', nodeId: SCAN_ID, path: [SCAN_ID, 'result'] },
|
|
40
|
+
itemVariable: 'cart',
|
|
41
|
+
},
|
|
42
|
+
executionEnv: 'server',
|
|
43
|
+
stepNumber: 2,
|
|
44
|
+
label: 'Loop Over Each Abandoned Cart',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: SHIM_ID,
|
|
48
|
+
type: 'general-custom-js',
|
|
49
|
+
config: { code: 'function customHandler(p){return {email:"a@b.c"}}', context: 'server' },
|
|
50
|
+
executionEnv: 'server',
|
|
51
|
+
stepNumber: 3,
|
|
52
|
+
label: 'Build Reminder Payload',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: CREATE_ID,
|
|
56
|
+
type: 'data-create-item',
|
|
57
|
+
config: { dataSourceId: 'ds-1', tableName: 'teleport_abandoned_carts', columnMappings: [] },
|
|
58
|
+
executionEnv: 'server',
|
|
59
|
+
stepNumber: 4,
|
|
60
|
+
label: 'Record Abandoned Cart',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: EMAIL_ID,
|
|
64
|
+
type: 'email-resend',
|
|
65
|
+
config: {
|
|
66
|
+
to: { type: 'workflowContext', nodeId: SHIM_ID, path: [SHIM_ID, 'email'] },
|
|
67
|
+
subject: 'You left something in your cart at {{companyName}}',
|
|
68
|
+
body: '<p>Hi {{customerName}}</p>',
|
|
69
|
+
templateParams: [
|
|
70
|
+
{ key: 'customerName', value: 'Jane' },
|
|
71
|
+
{ key: 'companyName', value: 'Acme' },
|
|
72
|
+
],
|
|
73
|
+
apiKey: '',
|
|
74
|
+
emailType: 'transactional',
|
|
75
|
+
},
|
|
76
|
+
executionEnv: 'server',
|
|
77
|
+
stepNumber: 5,
|
|
78
|
+
label: 'Send Abandoned Cart Reminder',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
edges: [
|
|
82
|
+
{ id: 'e1', source: TRIGGER_ID, target: SCAN_ID },
|
|
83
|
+
{ id: 'e2', source: SCAN_ID, target: LOOP_ID },
|
|
84
|
+
{
|
|
85
|
+
id: 'e3',
|
|
86
|
+
source: LOOP_ID,
|
|
87
|
+
target: SHIM_ID,
|
|
88
|
+
sourceHandle: 'loop',
|
|
89
|
+
targetHandle: 'loop-body-in',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: 'e4',
|
|
93
|
+
source: SHIM_ID,
|
|
94
|
+
target: CREATE_ID,
|
|
95
|
+
sourceHandle: 'loop-body-out',
|
|
96
|
+
targetHandle: 'loop-body-in',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'e5',
|
|
100
|
+
source: CREATE_ID,
|
|
101
|
+
target: EMAIL_ID,
|
|
102
|
+
sourceHandle: 'loop-body-out',
|
|
103
|
+
targetHandle: 'loop-body-in',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: 'e6',
|
|
107
|
+
source: EMAIL_ID,
|
|
108
|
+
target: LOOP_ID,
|
|
109
|
+
sourceHandle: 'loop-body-out',
|
|
110
|
+
targetHandle: 'loop-back',
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('generateCronAPIRoute — loop + templateParams support', () => {
|
|
116
|
+
const route = generateCronAPIRoute(buildCronWorkflow())
|
|
117
|
+
|
|
118
|
+
it('uses the loop-capable executor (handles general-loop body discovery)', () => {
|
|
119
|
+
// The shared execution loop keys the loop body off the 'loop' sourceHandle
|
|
120
|
+
// and walks 'loop-body-out' edges — markers absent from the old naive loop.
|
|
121
|
+
expect(route).toContain("e.sourceHandle === 'loop'")
|
|
122
|
+
expect(route).toContain('loop-body-out')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('merges templateParams into email body + subject', () => {
|
|
126
|
+
// Both the top-level and loop-body execution paths apply the merge; the
|
|
127
|
+
// email node lives in the loop body here.
|
|
128
|
+
expect(route).toContain('utils.applyTemplateParams(bResolved.body, bResolved.templateParams)')
|
|
129
|
+
expect(route).toContain(
|
|
130
|
+
'utils.applyTemplateParams(bResolved.subject, bResolved.templateParams)'
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('bakes the scan + loop + email nodes into the server WORKFLOW_CONFIG', () => {
|
|
135
|
+
expect(route).toContain('"type": "general-loop"')
|
|
136
|
+
expect(route).toContain('"type": "email-resend"')
|
|
137
|
+
expect(route).toContain('"type": "data-raw-query"')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('emits a self-contained cron handler with the schedule + success response', () => {
|
|
141
|
+
expect(route).toContain("schedule: '0 * * * *'")
|
|
142
|
+
expect(route).toContain('res.status(200).json({ success: true')
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { generateDataAPIRoute } from '../src/data-api-route-generator'
|
|
2
|
+
import { dataCreateItem } from '../src/nodes/data/data-create-item'
|
|
3
|
+
|
|
4
|
+
// Idempotent insert support. The "Resolve Current User" custom node re-ensures
|
|
5
|
+
// a guest's anonymous `users` row on EVERY resolution so the identity is
|
|
6
|
+
// self-healing after a database reset / re-provision. That insert targets a
|
|
7
|
+
// row that usually already exists, so it MUST be a no-op on conflict rather
|
|
8
|
+
// than a 23505 error that aborts the resolve-user (and therefore checkout).
|
|
9
|
+
|
|
10
|
+
describe('data-api handleCreate — ON CONFLICT DO NOTHING', () => {
|
|
11
|
+
const code = generateDataAPIRoute()
|
|
12
|
+
|
|
13
|
+
it('appends ON CONFLICT DO NOTHING only when the caller opts in', () => {
|
|
14
|
+
expect(code).toContain('body.onConflictDoNothing')
|
|
15
|
+
expect(code).toContain('ON CONFLICT DO NOTHING')
|
|
16
|
+
// Default path stays a plain insert — the clause is gated behind the flag.
|
|
17
|
+
expect(code).toMatch(/onConflictClause\s*=\s*body\.onConflictDoNothing\s*\?/)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('falls back to the supplied id when a conflict returns no row', () => {
|
|
21
|
+
// With ON CONFLICT DO NOTHING a pre-existing row yields no RETURNING row,
|
|
22
|
+
// so the response must still surface the intended id for downstream nodes.
|
|
23
|
+
expect(code).toContain("var idIdx = columns.indexOf('id')")
|
|
24
|
+
expect(code).toMatch(/resolvedId\s*=\s*item\s*\?/)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('data_create_item — forwards the idempotency flag', () => {
|
|
29
|
+
const handlerCode = dataCreateItem.generateHandler()
|
|
30
|
+
|
|
31
|
+
it('passes config.onConflictDoNothing through to the data-api request body', () => {
|
|
32
|
+
expect(handlerCode).toContain('config.onConflictDoNothing === true')
|
|
33
|
+
expect(handlerCode).toContain('reqBody.onConflictDoNothing = true')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -19,6 +19,8 @@ import { generateSharedRuntimeUtilsCode } from '../src'
|
|
|
19
19
|
type SharedUtils = {
|
|
20
20
|
resolveConfig: (cfg: unknown, ctx: Record<string, unknown>) => any
|
|
21
21
|
resolveRichTextContext: (html: string, ctx: Record<string, unknown>) => string
|
|
22
|
+
applyTemplateParams: (text: string, params: unknown) => string
|
|
23
|
+
expandListBlocks: (text: string, lists: Record<string, unknown>) => string
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
function loadSharedRuntime(): SharedUtils {
|
|
@@ -121,3 +123,127 @@ describe('email body inline-context resolution', () => {
|
|
|
121
123
|
expect(out).not.toContain('Reset URL</span>')
|
|
122
124
|
})
|
|
123
125
|
})
|
|
126
|
+
|
|
127
|
+
// Guards the component-body path: a send-email node whose body is a serialized
|
|
128
|
+
// email-template component carries flat {{token}} merge fields filled at runtime
|
|
129
|
+
// from templateParams (resolveConfig resolves each param's context ref first,
|
|
130
|
+
// then executeNodes calls applyTemplateParams on body + subject).
|
|
131
|
+
describe('email template params substitution', () => {
|
|
132
|
+
const utils = loadSharedRuntime()
|
|
133
|
+
|
|
134
|
+
it('exposes applyTemplateParams from the generated runtime', () => {
|
|
135
|
+
expect(typeof utils.applyTemplateParams).toBe('function')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('fills {{token}} in body + subject from resolved templateParams (executeNodes flow)', () => {
|
|
139
|
+
const config = {
|
|
140
|
+
subject: 'Reset {{recipientEmail}}',
|
|
141
|
+
body: '<a href="{{resetUrl}}">Reset</a> for {{recipientEmail}}',
|
|
142
|
+
templateParams: [
|
|
143
|
+
{
|
|
144
|
+
key: 'resetUrl',
|
|
145
|
+
value: { type: 'workflowContext', nodeId: 'N1', path: ['N1', 'result'] },
|
|
146
|
+
},
|
|
147
|
+
{ key: 'recipientEmail', value: 'jane@example.com' },
|
|
148
|
+
],
|
|
149
|
+
}
|
|
150
|
+
// Step 1: resolveConfig resolves each param's context ref (as executeNodes does).
|
|
151
|
+
const resolved = utils.resolveConfig(config, {
|
|
152
|
+
N1: { result: 'https://app.example.com/reset-password?token=abc' },
|
|
153
|
+
})
|
|
154
|
+
// Step 2: executeNodes applies the params to body + subject.
|
|
155
|
+
const body = utils.applyTemplateParams(resolved.body, resolved.templateParams)
|
|
156
|
+
const subject = utils.applyTemplateParams(resolved.subject, resolved.templateParams)
|
|
157
|
+
expect(body).toBe(
|
|
158
|
+
'<a href="https://app.example.com/reset-password?token=abc">Reset</a> for jane@example.com'
|
|
159
|
+
)
|
|
160
|
+
expect(subject).toBe('Reset jane@example.com')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('leaves unknown tokens verbatim (never blanks a campaign {{name}})', () => {
|
|
164
|
+
const out = utils.applyTemplateParams('Hi {{name}}, reset: {{resetUrl}}', [
|
|
165
|
+
{ key: 'resetUrl', value: 'https://x/reset?token=1' },
|
|
166
|
+
])
|
|
167
|
+
expect(out).toBe('Hi {{name}}, reset: https://x/reset?token=1')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('does not partial-match longer tokens ({{total}} vs {{totalAmount}})', () => {
|
|
171
|
+
const out = utils.applyTemplateParams('{{total}} of {{totalAmount}}', [
|
|
172
|
+
{ key: 'total', value: '5' },
|
|
173
|
+
{ key: 'totalAmount', value: '$99' },
|
|
174
|
+
])
|
|
175
|
+
expect(out).toBe('5 of $99')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('is a no-op when there are no params or body is not a string', () => {
|
|
179
|
+
expect(utils.applyTemplateParams('{{x}}', [])).toBe('{{x}}')
|
|
180
|
+
expect(utils.applyTemplateParams('{{x}}', undefined)).toBe('{{x}}')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// ── Array-mapper loop-block expansion (builder array mapper → email list) ──
|
|
184
|
+
|
|
185
|
+
it('expands a <!--tq:each--> block from an array templateParam, once per row', () => {
|
|
186
|
+
const body =
|
|
187
|
+
'Items:<!--tq:each products--><li>{{quantity}}x <strong>{{product_name}}</strong> — {{companyName}}</li><!--/tq:each-->end'
|
|
188
|
+
const out = utils.applyTemplateParams(body, [
|
|
189
|
+
{
|
|
190
|
+
key: 'products',
|
|
191
|
+
value: [
|
|
192
|
+
{ product_name: 'Espresso Beans', quantity: 2 },
|
|
193
|
+
{ product_name: 'Ceramic Mug', quantity: 1 },
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
{ key: 'companyName', value: 'Acme Inc.' },
|
|
197
|
+
])
|
|
198
|
+
// one <li> per row, row tokens filled from the row object
|
|
199
|
+
expect(out).toContain('<li>2x <strong>Espresso Beans</strong> — Acme Inc.</li>')
|
|
200
|
+
expect(out).toContain('<li>1x <strong>Ceramic Mug</strong> — Acme Inc.</li>')
|
|
201
|
+
// the {{companyName}} inside the row (not a row field) resolved via the flat fill
|
|
202
|
+
expect(out).not.toContain('{{companyName}}')
|
|
203
|
+
// no leftover sentinels or tokens
|
|
204
|
+
expect(out).not.toContain('<!--tq:each')
|
|
205
|
+
expect(out).not.toContain('<!--/tq:each-->')
|
|
206
|
+
expect(out).not.toContain('{{product_name}}')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('HTML-escapes per-row values (no markup injection from item data)', () => {
|
|
210
|
+
const out = utils.expandListBlocks('<!--tq:each products-->[{{name}}]<!--/tq:each-->', {
|
|
211
|
+
products: [{ name: '<b>x</b> & "y"' }],
|
|
212
|
+
})
|
|
213
|
+
expect(out).toBe('[<b>x</b> & "y"]')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('renders an empty string for a missing/non-array list key', () => {
|
|
217
|
+
const body = 'a<!--tq:each products-->{{name}}<!--/tq:each-->b'
|
|
218
|
+
expect(utils.applyTemplateParams(body, [])).toBe('ab')
|
|
219
|
+
expect(utils.applyTemplateParams(body, [{ key: 'products', value: 'not-an-array' }])).toBe('ab')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('resolveConfig preserves an array-valued templateParam so the block expands', () => {
|
|
223
|
+
// Mirrors the withdrawal send node: templateParams is an array of {key,value}
|
|
224
|
+
// where value is a workflowContext ref that resolves to the items array.
|
|
225
|
+
const config = {
|
|
226
|
+
body: 'X<!--tq:each products--><li>{{quantity}}x {{product_name}}</li><!--/tq:each-->Y',
|
|
227
|
+
templateParams: [
|
|
228
|
+
{
|
|
229
|
+
key: 'products',
|
|
230
|
+
value: {
|
|
231
|
+
type: 'workflowContext',
|
|
232
|
+
nodeId: 'assemble',
|
|
233
|
+
path: ['assemble', 'selectedItems'],
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{ key: 'companyName', value: 'Acme Inc.' },
|
|
237
|
+
],
|
|
238
|
+
}
|
|
239
|
+
const ctx = {
|
|
240
|
+
assemble: { selectedItems: [{ product_name: 'Espresso Beans', quantity: 3 }] },
|
|
241
|
+
}
|
|
242
|
+
const resolved = utils.resolveConfig(config, ctx)
|
|
243
|
+
// the array survived resolution (was not String()-joined)
|
|
244
|
+
expect(Array.isArray(resolved.templateParams[0].value)).toBe(true)
|
|
245
|
+
const out = utils.applyTemplateParams(resolved.body, resolved.templateParams)
|
|
246
|
+
expect(out).toContain('<li>3x Espresso Beans</li>')
|
|
247
|
+
expect(out).not.toContain('<!--tq:each')
|
|
248
|
+
})
|
|
249
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// `event-page-loaded` scoping guard: a page-loaded workflow with NO page
|
|
2
|
+
// scoping (no pageId, no selectedPages, no allPages) must be SKIPPED, not
|
|
3
|
+
// injected into every page. Silent all-pages fan-out masked mis-scoped
|
|
4
|
+
// workflows whose state target lives in a component (the setter no-ops on
|
|
5
|
+
// pages). Explicit `allPages: true` is the supported way to run everywhere.
|
|
6
|
+
|
|
7
|
+
import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
|
|
8
|
+
|
|
9
|
+
const pageLoadedWorkflow = (config: Record<string, unknown>) => ({
|
|
10
|
+
id: 'wf-page-loaded',
|
|
11
|
+
name: 'Page Init',
|
|
12
|
+
trigger: {
|
|
13
|
+
type: 'event-page-loaded',
|
|
14
|
+
nodeId: 'trigger-page-loaded',
|
|
15
|
+
scope: 'page',
|
|
16
|
+
config,
|
|
17
|
+
},
|
|
18
|
+
nodes: [
|
|
19
|
+
{
|
|
20
|
+
id: 'update-1',
|
|
21
|
+
type: 'state-update-local-state',
|
|
22
|
+
config: { property: 'ready', value: 'true' },
|
|
23
|
+
stepNumber: 1,
|
|
24
|
+
label: 'Set ready',
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
edges: [{ id: 'e1', source: 'trigger-page-loaded', target: 'update-1' }],
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const jsxComponentChunk = () => ({
|
|
31
|
+
type: 'chunk-type-ast',
|
|
32
|
+
name: 'jsx-component',
|
|
33
|
+
content: {
|
|
34
|
+
type: 'VariableDeclaration',
|
|
35
|
+
declarations: [
|
|
36
|
+
{
|
|
37
|
+
type: 'VariableDeclarator',
|
|
38
|
+
init: {
|
|
39
|
+
type: 'ArrowFunctionExpression',
|
|
40
|
+
body: {
|
|
41
|
+
type: 'BlockStatement',
|
|
42
|
+
body: [{ type: 'ReturnStatement', argument: null }],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const getWorkflowModule = async (workflow: any, pageId: string): Promise<string | null> => {
|
|
51
|
+
const plugin = createNextWorkflowPlugin({ isPage: true })
|
|
52
|
+
const structure: any = {
|
|
53
|
+
uidl: {
|
|
54
|
+
name: 'Page',
|
|
55
|
+
outputOptions: { pageId, fileName: pageId },
|
|
56
|
+
node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
|
|
57
|
+
stateDefinitions: { ready: { type: 'string', defaultValue: '' } },
|
|
58
|
+
},
|
|
59
|
+
chunks: [jsxComponentChunk()],
|
|
60
|
+
options: {
|
|
61
|
+
workflows: { workflows: { [workflow.id]: workflow }, customNodes: {} },
|
|
62
|
+
},
|
|
63
|
+
dependencies: {},
|
|
64
|
+
}
|
|
65
|
+
await plugin(structure)
|
|
66
|
+
const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module')
|
|
67
|
+
return moduleChunk ? String(moduleChunk.content) : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('event-page-loaded scoping', () => {
|
|
71
|
+
it('SKIPS a workflow with no page scoping at all (empty config)', async () => {
|
|
72
|
+
const code = await getWorkflowModule(pageLoadedWorkflow({}), 'page-1')
|
|
73
|
+
expect(code).toBeNull()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('generates into every page when allPages is true', async () => {
|
|
77
|
+
const codeA = await getWorkflowModule(pageLoadedWorkflow({ allPages: true }), 'page-1')
|
|
78
|
+
const codeB = await getWorkflowModule(pageLoadedWorkflow({ allPages: true }), 'page-2')
|
|
79
|
+
expect(codeA).toContain('Page loaded')
|
|
80
|
+
expect(codeB).toContain('Page loaded')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('generates only into the matching page when pageId is set', async () => {
|
|
84
|
+
const match = await getWorkflowModule(pageLoadedWorkflow({ pageId: 'page-1' }), 'page-1')
|
|
85
|
+
const mismatch = await getWorkflowModule(pageLoadedWorkflow({ pageId: 'page-1' }), 'page-2')
|
|
86
|
+
expect(match).toContain('Page loaded')
|
|
87
|
+
expect(mismatch).toBeNull()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('generates into pages listed in selectedPages', async () => {
|
|
91
|
+
const config = { selectedPages: [{ id: 'page-1' }, { id: 'page-3' }] }
|
|
92
|
+
const inList = await getWorkflowModule(pageLoadedWorkflow(config), 'page-3')
|
|
93
|
+
const notInList = await getWorkflowModule(pageLoadedWorkflow(config), 'page-2')
|
|
94
|
+
expect(inList).toContain('Page loaded')
|
|
95
|
+
expect(notInList).toBeNull()
|
|
96
|
+
})
|
|
97
|
+
})
|