@teleporthq/teleport-plugin-next-workflows 0.43.29 → 0.43.30
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__/_helpers/load-handler.ts +11 -1
- package/__tests__/file-storage-upload-picked-files.test.ts +135 -0
- package/__tests__/runtime-operator-aliases.test.ts +180 -0
- package/__tests__/runtime-template-token-resolution.test.ts +225 -0
- package/__tests__/state-update-array-type-guard.test.ts +172 -0
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +134 -5
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/nodes/data/data-count.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-count.js +16 -0
- package/dist/cjs/nodes/data/data-count.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 +20 -0
- package/dist/cjs/nodes/data/data-create-item.js.map +1 -1
- package/dist/cjs/nodes/data/data-delete-item.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-delete-item.js +19 -0
- package/dist/cjs/nodes/data/data-delete-item.js.map +1 -1
- package/dist/cjs/nodes/data/data-select.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-select.js +18 -0
- package/dist/cjs/nodes/data/data-select.js.map +1 -1
- package/dist/cjs/nodes/data/data-update-item.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-update-item.js +36 -0
- package/dist/cjs/nodes/data/data-update-item.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-component-plugin.js +56 -9
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +134 -5
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/nodes/data/data-count.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-count.js +16 -0
- package/dist/esm/nodes/data/data-count.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 +20 -0
- package/dist/esm/nodes/data/data-create-item.js.map +1 -1
- package/dist/esm/nodes/data/data-delete-item.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-delete-item.js +19 -0
- package/dist/esm/nodes/data/data-delete-item.js.map +1 -1
- package/dist/esm/nodes/data/data-select.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-select.js +18 -0
- package/dist/esm/nodes/data/data-select.js.map +1 -1
- package/dist/esm/nodes/data/data-update-item.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-update-item.js +36 -0
- package/dist/esm/nodes/data/data-update-item.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-component-plugin.js +56 -9
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/package.json +2 -2
- package/src/executor-generator.ts +134 -5
- package/src/nodes/data/data-count.ts +20 -0
- package/src/nodes/data/data-create-item.ts +19 -0
- package/src/nodes/data/data-delete-item.ts +23 -0
- package/src/nodes/data/data-select.ts +22 -0
- package/src/nodes/data/data-update-item.ts +38 -0
- package/src/workflow-component-plugin.ts +61 -3
|
@@ -16,7 +16,17 @@ import { nodeRegistry } from '../../src'
|
|
|
16
16
|
//
|
|
17
17
|
// (The polyfills are the verbatim TS 4.x emit, kept in sync with the
|
|
18
18
|
// upstream `__awaiter` / `__generator` definitions tslib ships.)
|
|
19
|
-
const TS_EMIT_HELPERS = `var
|
|
19
|
+
const TS_EMIT_HELPERS = `var __assign = (this && this.__assign) || function () {
|
|
20
|
+
__assign = Object.assign || function(t) {
|
|
21
|
+
for (var s, i = 1, n = arguments.length; i < n; i++) {
|
|
22
|
+
s = arguments[i];
|
|
23
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
|
|
24
|
+
}
|
|
25
|
+
return t;
|
|
26
|
+
};
|
|
27
|
+
return __assign.apply(this, arguments);
|
|
28
|
+
};
|
|
29
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
20
30
|
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
21
31
|
return new (P || (P = Promise))(function (resolve, reject) {
|
|
22
32
|
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Pins the file-storage-upload contract the tq-form-file-input primitive
|
|
2
|
+
// relies on: the form-submit workflow does state-get-local-state(<state>) ->
|
|
3
|
+
// file-storage-upload(file = <PickedFile[]>), so the handler must accept an
|
|
4
|
+
// ARRAY of `{ name, size, type, lastModified, dataURL }` POJOs (the exact
|
|
5
|
+
// shape browser-pick-files emits) and convert every dataURL to a Blob before
|
|
6
|
+
// POSTing the FormData.
|
|
7
|
+
|
|
8
|
+
import { Blob as NodeBlob } from 'buffer'
|
|
9
|
+
import { loadHandler, HandlerFn } from './_helpers/load-handler'
|
|
10
|
+
|
|
11
|
+
// jest 26's node sandbox exposes none of the browser upload globals the
|
|
12
|
+
// handler uses (FormData/Blob/atob/fetch), so the test installs minimal
|
|
13
|
+
// stand-ins: Node's own Blob, a Buffer-backed atob, and a FormData stub
|
|
14
|
+
// that records appends the way the assertions need them.
|
|
15
|
+
class FormDataStub {
|
|
16
|
+
public appended: Array<{ key: string; value: unknown; fileName?: string }> = []
|
|
17
|
+
|
|
18
|
+
public append(key: string, value: unknown, fileName?: string): void {
|
|
19
|
+
this.appended.push({ key, value, fileName })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public getAll(key: string): Array<{ value: unknown; fileName?: string }> {
|
|
23
|
+
return this.appended
|
|
24
|
+
.filter((entry) => entry.key === key)
|
|
25
|
+
.map((entry) => ({ value: entry.value, fileName: entry.fileName }))
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const toDataURL = (mime: string, content: string): string =>
|
|
30
|
+
`data:${mime};base64,${Buffer.from(content).toString('base64')}`
|
|
31
|
+
|
|
32
|
+
const blobText = async (value: unknown): Promise<string> =>
|
|
33
|
+
Buffer.from(await (value as InstanceType<typeof NodeBlob>).arrayBuffer()).toString()
|
|
34
|
+
|
|
35
|
+
describe('file-storage-upload: PickedFile array from state', () => {
|
|
36
|
+
let fetchMock: jest.Mock
|
|
37
|
+
let handler: HandlerFn
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
handler = loadHandler('file-storage-upload')
|
|
41
|
+
fetchMock = jest.fn(async () => ({
|
|
42
|
+
ok: true,
|
|
43
|
+
json: async () => ({
|
|
44
|
+
files: [
|
|
45
|
+
{ id: '1', name: 'a.png', url: 'https://storage.example/a.png' },
|
|
46
|
+
{ id: '2', name: 'b.txt', url: 'https://storage.example/b.txt' },
|
|
47
|
+
],
|
|
48
|
+
}),
|
|
49
|
+
}))
|
|
50
|
+
;(globalThis as any).fetch = fetchMock
|
|
51
|
+
;(globalThis as any).FormData = FormDataStub
|
|
52
|
+
;(globalThis as any).Blob = NodeBlob
|
|
53
|
+
;(globalThis as any).atob = (encoded: string) =>
|
|
54
|
+
Buffer.from(encoded, 'base64').toString('binary')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
delete (globalThis as any).fetch
|
|
59
|
+
delete (globalThis as any).FormData
|
|
60
|
+
delete (globalThis as any).Blob
|
|
61
|
+
delete (globalThis as any).atob
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('converts every PickedFile dataURL to a Blob and appends all of them', async () => {
|
|
65
|
+
const pickedFiles = [
|
|
66
|
+
{
|
|
67
|
+
name: 'a.png',
|
|
68
|
+
size: 9,
|
|
69
|
+
type: 'image/png',
|
|
70
|
+
lastModified: 1,
|
|
71
|
+
dataURL: toDataURL('image/png', 'png-bytes'),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'b.txt',
|
|
75
|
+
size: 9,
|
|
76
|
+
type: 'text/plain',
|
|
77
|
+
lastModified: 2,
|
|
78
|
+
dataURL: toDataURL('text/plain', 'txt-bytes'),
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
const result = (await handler({ file: pickedFiles }, {})) as { files: Array<{ url: string }> }
|
|
83
|
+
|
|
84
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
85
|
+
const [url, init] = fetchMock.mock.calls[0]
|
|
86
|
+
expect(url).toBe('/api/runtime-storage/upload')
|
|
87
|
+
const entries = (init.body as FormDataStub).getAll('file')
|
|
88
|
+
expect(entries).toHaveLength(2)
|
|
89
|
+
expect(entries[0].fileName).toBe('a.png')
|
|
90
|
+
expect((entries[0].value as InstanceType<typeof NodeBlob>).type).toBe('image/png')
|
|
91
|
+
expect(await blobText(entries[0].value)).toBe('png-bytes')
|
|
92
|
+
expect(entries[1].fileName).toBe('b.txt')
|
|
93
|
+
expect((entries[1].value as InstanceType<typeof NodeBlob>).type).toBe('text/plain')
|
|
94
|
+
expect(await blobText(entries[1].value)).toBe('txt-bytes')
|
|
95
|
+
|
|
96
|
+
expect(result.files.map((f) => f.url)).toEqual([
|
|
97
|
+
'https://storage.example/a.png',
|
|
98
|
+
'https://storage.example/b.txt',
|
|
99
|
+
])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('skips array entries without a usable dataURL but uploads the rest', async () => {
|
|
103
|
+
const pickedFiles = [
|
|
104
|
+
{ name: 'broken.png', size: 1, type: 'image/png', lastModified: 1 },
|
|
105
|
+
{
|
|
106
|
+
name: 'ok.png',
|
|
107
|
+
size: 9,
|
|
108
|
+
type: 'image/png',
|
|
109
|
+
lastModified: 2,
|
|
110
|
+
dataURL: toDataURL('image/png', 'ok-bytes'),
|
|
111
|
+
},
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
await handler({ file: pickedFiles }, {})
|
|
115
|
+
|
|
116
|
+
const entries = (fetchMock.mock.calls[0][1].body as FormDataStub).getAll('file')
|
|
117
|
+
expect(entries).toHaveLength(1)
|
|
118
|
+
expect(entries[0].fileName).toBe('ok.png')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('returns an error without fetching when no array entry is convertible', async () => {
|
|
122
|
+
const result = await handler({ file: [{ name: 'x.png', type: 'image/png' }] }, {})
|
|
123
|
+
|
|
124
|
+
expect(result).toEqual({ files: [], error: 'Invalid file data' })
|
|
125
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('never throws for an empty selection — the submit chain needs no if-gate', async () => {
|
|
129
|
+
// An untouched form-file-input state is [] and reaches the node as-is.
|
|
130
|
+
const result = await handler({ file: [] }, {})
|
|
131
|
+
|
|
132
|
+
expect(result).toEqual({ files: [], error: 'Invalid file data' })
|
|
133
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { generateSharedRuntimeUtilsCode } from '../src'
|
|
2
|
+
|
|
3
|
+
// Regression guard for the operator-vocabulary mismatch (FurniFlow run
|
|
4
|
+
// be21af83): the worker canonicalizes if-statement operators to snake_case
|
|
5
|
+
// (is_not_empty, greater_than) and the GUI emits camelCase named forms
|
|
6
|
+
// (startsWith, notContains), but the generated runtime only implemented the
|
|
7
|
+
// hyphenated spellings. Unknown operators fell through to `left == right`,
|
|
8
|
+
// INVERTING unary semantics — a correctly filled stock-movement form
|
|
9
|
+
// evaluated is_not_empty(value) as `value == undefined` → false → the
|
|
10
|
+
// submit silently no-oped and success/error toasts were swapped.
|
|
11
|
+
|
|
12
|
+
type SharedUtils = {
|
|
13
|
+
evaluateSingleComparison: (cfg: unknown, ctx: Record<string, unknown>) => boolean
|
|
14
|
+
evaluateCondition: (cfg: unknown, ctx: Record<string, unknown>) => boolean
|
|
15
|
+
normalizeComparisonOperator: (op: unknown) => string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadSharedRuntime(): SharedUtils {
|
|
19
|
+
const src = generateSharedRuntimeUtilsCode()
|
|
20
|
+
const wrapper: { exports: Record<string, unknown> } = { exports: {} }
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
22
|
+
new Function('module', 'exports', src)(wrapper, wrapper.exports)
|
|
23
|
+
return wrapper.exports as unknown as SharedUtils
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('workflow runtime operator aliases', () => {
|
|
27
|
+
const utils = loadSharedRuntime()
|
|
28
|
+
|
|
29
|
+
it('normalizes snake_case, camelCase and named operators to the canonical form', () => {
|
|
30
|
+
expect(utils.normalizeComparisonOperator('is_not_empty')).toBe('is-not-empty')
|
|
31
|
+
expect(utils.normalizeComparisonOperator('is_empty')).toBe('is-empty')
|
|
32
|
+
expect(utils.normalizeComparisonOperator('not_equals')).toBe('not-equals')
|
|
33
|
+
expect(utils.normalizeComparisonOperator('greater_than')).toBe('greater-than')
|
|
34
|
+
expect(utils.normalizeComparisonOperator('less_than')).toBe('less-than')
|
|
35
|
+
expect(utils.normalizeComparisonOperator('greater_than_or_equal')).toBe('greater-than-or-equal')
|
|
36
|
+
expect(utils.normalizeComparisonOperator('less_than_or_equal')).toBe('less-than-or-equal')
|
|
37
|
+
expect(utils.normalizeComparisonOperator('starts_with')).toBe('starts-with')
|
|
38
|
+
expect(utils.normalizeComparisonOperator('startsWith')).toBe('starts-with')
|
|
39
|
+
expect(utils.normalizeComparisonOperator('ends_with')).toBe('ends-with')
|
|
40
|
+
expect(utils.normalizeComparisonOperator('endsWith')).toBe('ends-with')
|
|
41
|
+
expect(utils.normalizeComparisonOperator('not_contains')).toBe('not-contains')
|
|
42
|
+
expect(utils.normalizeComparisonOperator('notContains')).toBe('not-contains')
|
|
43
|
+
expect(utils.normalizeComparisonOperator('equals')).toBe('equals')
|
|
44
|
+
expect(utils.normalizeComparisonOperator('matchesRegex')).toBe('matches-regex')
|
|
45
|
+
// symbol operators pass through untouched
|
|
46
|
+
expect(utils.normalizeComparisonOperator('===')).toBe('===')
|
|
47
|
+
expect(utils.normalizeComparisonOperator('!=')).toBe('!=')
|
|
48
|
+
expect(utils.normalizeComparisonOperator('>')).toBe('>')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('evaluates is_not_empty as a UNARY check (previously inverted)', () => {
|
|
52
|
+
// filled value → true (used to be `value == undefined` → false)
|
|
53
|
+
expect(
|
|
54
|
+
utils.evaluateSingleComparison({ leftValue: 'receipt', operator: 'is_not_empty' }, {})
|
|
55
|
+
).toBe(true)
|
|
56
|
+
// empty value → false (used to be true)
|
|
57
|
+
expect(utils.evaluateSingleComparison({ leftValue: '', operator: 'is_not_empty' }, {})).toBe(
|
|
58
|
+
false
|
|
59
|
+
)
|
|
60
|
+
expect(
|
|
61
|
+
utils.evaluateSingleComparison({ leftValue: undefined, operator: 'is_not_empty' }, {})
|
|
62
|
+
).toBe(false)
|
|
63
|
+
expect(utils.evaluateSingleComparison({ leftValue: [], operator: 'is_not_empty' }, {})).toBe(
|
|
64
|
+
false
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('evaluates is_empty / is_null / is_not_null / is_true / is_false', () => {
|
|
69
|
+
expect(utils.evaluateSingleComparison({ leftValue: '', operator: 'is_empty' }, {})).toBe(true)
|
|
70
|
+
expect(utils.evaluateSingleComparison({ leftValue: 'x', operator: 'is_empty' }, {})).toBe(false)
|
|
71
|
+
expect(utils.evaluateSingleComparison({ leftValue: null, operator: 'is_null' }, {})).toBe(true)
|
|
72
|
+
expect(utils.evaluateSingleComparison({ leftValue: '', operator: 'is_null' }, {})).toBe(false)
|
|
73
|
+
expect(utils.evaluateSingleComparison({ leftValue: 0, operator: 'is_not_null' }, {})).toBe(true)
|
|
74
|
+
expect(utils.evaluateSingleComparison({ leftValue: 'true', operator: 'is_true' }, {})).toBe(
|
|
75
|
+
true
|
|
76
|
+
)
|
|
77
|
+
expect(utils.evaluateSingleComparison({ leftValue: false, operator: 'is_false' }, {})).toBe(
|
|
78
|
+
true
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('evaluates named binary operators', () => {
|
|
83
|
+
expect(
|
|
84
|
+
utils.evaluateSingleComparison(
|
|
85
|
+
{ leftValue: 5, rightValue: '3', operator: 'greater_than' },
|
|
86
|
+
{}
|
|
87
|
+
)
|
|
88
|
+
).toBe(true)
|
|
89
|
+
expect(
|
|
90
|
+
utils.evaluateSingleComparison({ leftValue: 2, rightValue: '3', operator: 'less_than' }, {})
|
|
91
|
+
).toBe(true)
|
|
92
|
+
expect(
|
|
93
|
+
utils.evaluateSingleComparison(
|
|
94
|
+
{ leftValue: 3, rightValue: '3', operator: 'greater_than_or_equal' },
|
|
95
|
+
{}
|
|
96
|
+
)
|
|
97
|
+
).toBe(true)
|
|
98
|
+
expect(
|
|
99
|
+
utils.evaluateSingleComparison(
|
|
100
|
+
{ leftValue: 3, rightValue: '3', operator: 'less_than_or_equal' },
|
|
101
|
+
{}
|
|
102
|
+
)
|
|
103
|
+
).toBe(true)
|
|
104
|
+
expect(
|
|
105
|
+
utils.evaluateSingleComparison(
|
|
106
|
+
{ leftValue: 'a', rightValue: 'b', operator: 'not_equals' },
|
|
107
|
+
{}
|
|
108
|
+
)
|
|
109
|
+
).toBe(true)
|
|
110
|
+
expect(
|
|
111
|
+
utils.evaluateSingleComparison(
|
|
112
|
+
{ leftValue: 'receipt-1', rightValue: 'receipt', operator: 'startsWith' },
|
|
113
|
+
{}
|
|
114
|
+
)
|
|
115
|
+
).toBe(true)
|
|
116
|
+
expect(
|
|
117
|
+
utils.evaluateSingleComparison(
|
|
118
|
+
{ leftValue: 'receipt-1', rightValue: '-1', operator: 'ends_with' },
|
|
119
|
+
{}
|
|
120
|
+
)
|
|
121
|
+
).toBe(true)
|
|
122
|
+
expect(
|
|
123
|
+
utils.evaluateSingleComparison(
|
|
124
|
+
{ leftValue: 'abc', rightValue: 'z', operator: 'notContains' },
|
|
125
|
+
{}
|
|
126
|
+
)
|
|
127
|
+
).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('warns on a genuinely unknown operator instead of silently comparing', () => {
|
|
131
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
132
|
+
utils.evaluateSingleComparison({ leftValue: 1, rightValue: 1, operator: 'sorta-equalish' }, {})
|
|
133
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('sorta-equalish'))
|
|
134
|
+
warnSpy.mockRestore()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('validate-input multiple-conditions AND from the FurniFlow run now passes for a filled form', () => {
|
|
138
|
+
// Deployed `Record Stock Movement.validate-input` shape (after server
|
|
139
|
+
// repair): conditions[] with leftValue/rightValue keys, snake_case ops
|
|
140
|
+
// and stringified numeric rights.
|
|
141
|
+
const context = {
|
|
142
|
+
'get-type': { value: 'receipt', key: 'movementType' },
|
|
143
|
+
'get-product': { value: 'b8b7e9aa-1111-4222-8333-abcdefabcdef', key: 'productId' },
|
|
144
|
+
'get-qty': { value: 12, key: 'quantity' },
|
|
145
|
+
}
|
|
146
|
+
const config = {
|
|
147
|
+
conditionType: 'multiple-conditions',
|
|
148
|
+
logicOperator: 'AND',
|
|
149
|
+
conditions: [
|
|
150
|
+
{
|
|
151
|
+
leftValue: { type: 'workflowContext', nodeId: 'get-type', path: ['get-type', 'value'] },
|
|
152
|
+
operator: 'is_not_empty',
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
leftValue: {
|
|
156
|
+
type: 'workflowContext',
|
|
157
|
+
nodeId: 'get-product',
|
|
158
|
+
path: ['get-product', 'value'],
|
|
159
|
+
},
|
|
160
|
+
operator: 'is_not_empty',
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
leftValue: { type: 'workflowContext', nodeId: 'get-qty', path: ['get-qty', 'value'] },
|
|
164
|
+
operator: '>',
|
|
165
|
+
rightValue: '0',
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
}
|
|
169
|
+
expect(utils.evaluateCondition(config, context)).toBe(true)
|
|
170
|
+
|
|
171
|
+
// Empty form → false (and the toast inversion scenario: is_not_empty on a
|
|
172
|
+
// missing insert id must be FALSE, not true)
|
|
173
|
+
const emptyContext = {
|
|
174
|
+
'get-type': { value: '', key: 'movementType' },
|
|
175
|
+
'get-product': { value: '', key: 'productId' },
|
|
176
|
+
'get-qty': { value: 0, key: 'quantity' },
|
|
177
|
+
}
|
|
178
|
+
expect(utils.evaluateCondition(config, emptyContext)).toBe(false)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { generateSharedRuntimeUtilsCode } from '../src'
|
|
2
|
+
import { loadHandler } from './_helpers/load-handler'
|
|
3
|
+
|
|
4
|
+
// Regression guard for the literal {{...}} template tokens that deployed
|
|
5
|
+
// node configs carry into the generated runtime (FurniFlow run be21af83):
|
|
6
|
+
// {{urlDifferentiator}} / {{Current Page Entity.id}} / {{page.entityId}}
|
|
7
|
+
// (details-page route param), {{state.X}} (trigger-time state snapshot) and
|
|
8
|
+
// {{Current User.id}} (GlobalContext bridge). No other substitution site
|
|
9
|
+
// exists in the generated app, so before this fix the literal token text
|
|
10
|
+
// reached SQL — breaking every edit/delete/save workflow on details pages.
|
|
11
|
+
|
|
12
|
+
const SENTINEL = '__TQ_UNRESOLVED_ROUTE_PARAM__'
|
|
13
|
+
|
|
14
|
+
type SharedUtils = {
|
|
15
|
+
resolveConfig: (cfg: unknown, ctx: Record<string, unknown>) => any
|
|
16
|
+
resolveTemplateTokenString: (
|
|
17
|
+
value: unknown,
|
|
18
|
+
ctx: Record<string, unknown>
|
|
19
|
+
) => { matched: boolean; value?: unknown }
|
|
20
|
+
finalizeResolvedConfig: (nodeType: string, cfg: unknown) => string | null
|
|
21
|
+
executeNodes: (
|
|
22
|
+
nodes: unknown[],
|
|
23
|
+
edges: unknown[],
|
|
24
|
+
ctx: Record<string, unknown>,
|
|
25
|
+
handlers: Record<string, unknown>,
|
|
26
|
+
workflowConfig: Record<string, unknown>
|
|
27
|
+
) => Promise<void>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadSharedRuntime(): SharedUtils {
|
|
31
|
+
const src = generateSharedRuntimeUtilsCode()
|
|
32
|
+
const wrapper: { exports: Record<string, unknown> } = { exports: {} }
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
34
|
+
new Function('module', 'exports', src)(wrapper, wrapper.exports)
|
|
35
|
+
return wrapper.exports as unknown as SharedUtils
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('workflow runtime template-token resolution', () => {
|
|
39
|
+
const utils = loadSharedRuntime()
|
|
40
|
+
|
|
41
|
+
describe('{{state.X}}', () => {
|
|
42
|
+
it('resolves from the trigger-time state snapshot', () => {
|
|
43
|
+
const ctx = { __stateValues: { searchQuery: 'oak desk' } }
|
|
44
|
+
const resolved = utils.resolveConfig(
|
|
45
|
+
{ filters: [{ field: 'name', value: '{{state.searchQuery}}', operator: 'ILIKE' }] },
|
|
46
|
+
ctx
|
|
47
|
+
)
|
|
48
|
+
expect(resolved.filters[0].value).toBe('oak desk')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('resolves nested paths and degrades a missing state to null', () => {
|
|
52
|
+
const ctx = { __stateValues: { form: { qty: 3 } } }
|
|
53
|
+
expect(utils.resolveTemplateTokenString('{{state.form.qty}}', ctx)).toEqual({
|
|
54
|
+
matched: true,
|
|
55
|
+
value: 3,
|
|
56
|
+
})
|
|
57
|
+
expect(utils.resolveTemplateTokenString('{{state.missing}}', ctx)).toEqual({
|
|
58
|
+
matched: true,
|
|
59
|
+
value: null,
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('does NOT touch JS-expression tokens (ternaries etc.)', () => {
|
|
64
|
+
const ternary = "{{state.categorySelect === 'All' ? undefined : state.categorySelect}}"
|
|
65
|
+
expect(utils.resolveTemplateTokenString(ternary, {}).matched).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('{{urlDifferentiator}} / {{Current Page Entity.id}} / {{page.entityId}}', () => {
|
|
70
|
+
const routeCtx = {
|
|
71
|
+
__routeParams: { id: 'b8b7e9aa-1111-4222-8333-abcdefabcdef' },
|
|
72
|
+
__dynamicRouteParam: 'id',
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
it.each(['{{urlDifferentiator}}', '{{Current Page Entity.id}}', '{{page.entityId}}'])(
|
|
76
|
+
'resolves %s to the dynamic-route param value',
|
|
77
|
+
(token) => {
|
|
78
|
+
const resolved = utils.resolveConfig(
|
|
79
|
+
{ filters: [{ field: 'id', value: token, operator: '=' }] },
|
|
80
|
+
routeCtx
|
|
81
|
+
)
|
|
82
|
+
expect(resolved.filters[0].value).toBe('b8b7e9aa-1111-4222-8333-abcdefabcdef')
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
it('falls back to the "id" param when no __dynamicRouteParam is declared', () => {
|
|
87
|
+
const ctx = { __routeParams: { id: 'abc-123' } }
|
|
88
|
+
expect(utils.resolveTemplateTokenString('{{urlDifferentiator}}', ctx)).toEqual({
|
|
89
|
+
matched: true,
|
|
90
|
+
value: 'abc-123',
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('resolves to the sentinel on a page with no route params', () => {
|
|
95
|
+
expect(utils.resolveTemplateTokenString('{{Current Page Entity.id}}', {})).toEqual({
|
|
96
|
+
matched: true,
|
|
97
|
+
value: SENTINEL,
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('{{Current User.id}}', () => {
|
|
103
|
+
it('resolves from the GlobalContext-bridged currentUser', () => {
|
|
104
|
+
const ctx = { __stateValues: { currentUser: { id: 'user-42', email: 'a@b.c' } } }
|
|
105
|
+
const resolved = utils.resolveConfig(
|
|
106
|
+
{ columnMappings: [{ column: 'created_by_user_id', value: '{{Current User.id}}' }] },
|
|
107
|
+
ctx
|
|
108
|
+
)
|
|
109
|
+
expect(resolved.columnMappings[0].value).toBe('user-42')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('resolves to null when no session exists', () => {
|
|
113
|
+
expect(utils.resolveTemplateTokenString('{{Current User.id}}', {})).toEqual({
|
|
114
|
+
matched: true,
|
|
115
|
+
value: null,
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('finalizeResolvedConfig', () => {
|
|
121
|
+
it('turns an unresolved route param in a data-node filter into a validation error', () => {
|
|
122
|
+
const cfg = { filters: [{ field: 'id', value: SENTINEL, operator: '=' }] }
|
|
123
|
+
const error = utils.finalizeResolvedConfig('data-select', cfg)
|
|
124
|
+
expect(error).toContain('id')
|
|
125
|
+
expect(error).toContain('not available')
|
|
126
|
+
expect(cfg.filters[0].value).toBeNull()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('degrades an unresolved route param outside filters to null without erroring', () => {
|
|
130
|
+
const cfg = { columnMappings: [{ column: 'product_id', value: SENTINEL }] }
|
|
131
|
+
expect(utils.finalizeResolvedConfig('data-create-item', cfg)).toBeNull()
|
|
132
|
+
expect(cfg.columnMappings[0].value).toBeNull()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('leaves configs without sentinels untouched', () => {
|
|
136
|
+
const cfg = { filters: [{ field: 'id', value: null, operator: '=' }] }
|
|
137
|
+
expect(utils.finalizeResolvedConfig('data-select', cfg)).toBeNull()
|
|
138
|
+
expect(cfg.filters[0].value).toBeNull()
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('executeNodes routes the validation error to the workflow error handler', () => {
|
|
143
|
+
it('throws (→ error handler) instead of calling the data handler with the literal', async () => {
|
|
144
|
+
const handlerCalls: unknown[] = []
|
|
145
|
+
const handlers = {
|
|
146
|
+
'data-select': async (cfg: unknown) => {
|
|
147
|
+
handlerCalls.push(cfg)
|
|
148
|
+
return { rows: [] }
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
const nodes = [
|
|
152
|
+
{
|
|
153
|
+
id: 'fetch-product-data',
|
|
154
|
+
type: 'data-select',
|
|
155
|
+
stepNumber: 1,
|
|
156
|
+
config: {
|
|
157
|
+
tableName: 'products',
|
|
158
|
+
filters: [{ field: 'id', value: '{{urlDifferentiator}}', operator: '=' }],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
]
|
|
162
|
+
// No route params in context → the token resolves to the sentinel.
|
|
163
|
+
await expect(
|
|
164
|
+
utils.executeNodes(nodes, [], {}, handlers, { triggerNodeId: 't' })
|
|
165
|
+
).rejects.toThrow(/not available/)
|
|
166
|
+
expect(handlerCalls).toHaveLength(0)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('generated data-node handlers reject the sentinel defensively', () => {
|
|
171
|
+
// The API-route segment executor resolves configs without the
|
|
172
|
+
// executeNodes finalize pass, so the handlers themselves must catch a
|
|
173
|
+
// sentinel that slipped through.
|
|
174
|
+
it('data-select returns an error result for a sentinel filter', async () => {
|
|
175
|
+
const handler = loadHandler('data-select')
|
|
176
|
+
const result = await handler(
|
|
177
|
+
{ tableName: 'products', filters: [{ field: 'id', value: SENTINEL, operator: '=' }] },
|
|
178
|
+
{}
|
|
179
|
+
)
|
|
180
|
+
expect(result.error).toContain('not available')
|
|
181
|
+
expect(result.rows).toEqual([])
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('data-update-item returns an error result for a sentinel filter', async () => {
|
|
185
|
+
const handler = loadHandler('data-update-item')
|
|
186
|
+
const result = await handler(
|
|
187
|
+
{
|
|
188
|
+
tableName: 'products',
|
|
189
|
+
filters: [{ field: 'id', value: SENTINEL, operator: '=' }],
|
|
190
|
+
columnMappings: [{ column: 'name', value: 'X' }],
|
|
191
|
+
},
|
|
192
|
+
{}
|
|
193
|
+
)
|
|
194
|
+
expect(result.error).toContain('not available')
|
|
195
|
+
expect(result.updatedCount).toBe(0)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('data-create-item nulls sentinel columnMappings so the INSERT survives', async () => {
|
|
199
|
+
const fetchMock = jest.fn(async () => ({
|
|
200
|
+
ok: true,
|
|
201
|
+
json: async () => ({ id: 'new-id', item: { id: 'new-id' } }),
|
|
202
|
+
}))
|
|
203
|
+
const originalFetch = (globalThis as any).fetch
|
|
204
|
+
;(globalThis as any).fetch = fetchMock
|
|
205
|
+
try {
|
|
206
|
+
const handler = loadHandler('data-create-item')
|
|
207
|
+
const result = await handler(
|
|
208
|
+
{
|
|
209
|
+
tableName: 'stock_movements',
|
|
210
|
+
columnMappings: [
|
|
211
|
+
{ column: 'movement_type', value: 'receipt' },
|
|
212
|
+
{ column: 'product_id', value: SENTINEL },
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
{}
|
|
216
|
+
)
|
|
217
|
+
expect(result.id).toBe('new-id')
|
|
218
|
+
const body = JSON.parse((fetchMock.mock.calls[0] as any[])[1].body)
|
|
219
|
+
expect(body.columnMappings[1].value).toBeNull()
|
|
220
|
+
} finally {
|
|
221
|
+
;(globalThis as any).fetch = originalFetch
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
})
|