@teleporthq/teleport-plugin-next-workflows 0.43.26 → 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__/salesforce-opportunity-id.test.ts +122 -0
- package/__tests__/state-update-array-type-guard.test.ts +172 -0
- package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
- package/dist/cjs/data-api-route-generator.js +10 -0
- package/dist/cjs/data-api-route-generator.js.map +1 -1
- 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 +26 -1
- 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 +43 -1
- package/dist/cjs/nodes/data/data-update-item.js.map +1 -1
- package/dist/cjs/nodes/integrations/integration-salesforce.d.ts.map +1 -1
- package/dist/cjs/nodes/integrations/integration-salesforce.js +3 -3
- package/dist/cjs/nodes/integrations/integration-salesforce.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/cjs/workflow-project-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-project-plugin.js +34 -9
- package/dist/cjs/workflow-project-plugin.js.map +1 -1
- package/dist/esm/data-api-route-generator.d.ts.map +1 -1
- package/dist/esm/data-api-route-generator.js +10 -0
- package/dist/esm/data-api-route-generator.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 +26 -1
- 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 +43 -1
- package/dist/esm/nodes/data/data-update-item.js.map +1 -1
- package/dist/esm/nodes/integrations/integration-salesforce.d.ts.map +1 -1
- package/dist/esm/nodes/integrations/integration-salesforce.js +3 -3
- package/dist/esm/nodes/integrations/integration-salesforce.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/dist/esm/workflow-project-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-project-plugin.js +34 -9
- package/dist/esm/workflow-project-plugin.js.map +1 -1
- package/package.json +2 -2
- package/src/data-api-route-generator.ts +10 -0
- 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 +30 -1
- package/src/nodes/data/data-select.ts +22 -0
- package/src/nodes/data/data-update-item.ts +45 -1
- package/src/nodes/integrations/integration-salesforce.ts +22 -13
- package/src/workflow-component-plugin.ts +61 -3
- package/src/workflow-project-plugin.ts +29 -4
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/* tslint:disable:no-eval */
|
|
2
|
+
import { integrationSalesforce } from '../src/nodes/integrations/integration-salesforce'
|
|
3
|
+
|
|
4
|
+
// Regression: the GUI Salesforce node form writes the opportunity id under
|
|
5
|
+
// `config.opportunityId` (matching the convert-lead case and the contactId /
|
|
6
|
+
// leadId / accountId / caseId convention used everywhere else in this handler),
|
|
7
|
+
// but the update / get / delete opportunity cases read `config.oppId`, which is
|
|
8
|
+
// never populated. Every request therefore hit `…/sobjects/Opportunity/undefined`
|
|
9
|
+
// and 404'd. The fix reads `config.opportunityId || config.oppId` so the form
|
|
10
|
+
// value is used, while any already-saved workflow that stored `oppId` still works.
|
|
11
|
+
|
|
12
|
+
function evalHandler(code: string): any {
|
|
13
|
+
return eval('(' + code + ')')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Capturing fetch double: records every requested URL and returns a 2xx so the
|
|
17
|
+
// handler walks the success path. `text()` backs the handler's `__readJson`.
|
|
18
|
+
function makeFetch(urls: string[]) {
|
|
19
|
+
return async (url: string) => {
|
|
20
|
+
urls.push(url)
|
|
21
|
+
return { ok: true, text: async () => '{}' }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('salesforce opportunity id resolution', () => {
|
|
26
|
+
const baseConfig = {
|
|
27
|
+
accessToken: 'tok',
|
|
28
|
+
instanceUrl: 'https://my.salesforce.com',
|
|
29
|
+
}
|
|
30
|
+
const urlPrefix = 'https://my.salesforce.com/services/data/v58.0/sobjects/Opportunity/'
|
|
31
|
+
|
|
32
|
+
it.each(['update-opportunity', 'get-opportunity', 'delete-opportunity'])(
|
|
33
|
+
'%s uses config.opportunityId in the request URL',
|
|
34
|
+
async (action) => {
|
|
35
|
+
const handler = evalHandler(integrationSalesforce.generateHandler())
|
|
36
|
+
const urls: string[] = []
|
|
37
|
+
;(global as any).fetch = makeFetch(urls)
|
|
38
|
+
try {
|
|
39
|
+
const res = await handler({ ...baseConfig, action, opportunityId: 'OPP-real' }, {})
|
|
40
|
+
expect(res.success).toBe(true)
|
|
41
|
+
expect(urls).toEqual([urlPrefix + 'OPP-real'])
|
|
42
|
+
expect(urls[0]).not.toContain('undefined')
|
|
43
|
+
} finally {
|
|
44
|
+
delete (global as any).fetch
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
it.each(['update-opportunity', 'get-opportunity', 'delete-opportunity'])(
|
|
50
|
+
'%s falls back to legacy config.oppId when opportunityId is absent',
|
|
51
|
+
async (action) => {
|
|
52
|
+
const handler = evalHandler(integrationSalesforce.generateHandler())
|
|
53
|
+
const urls: string[] = []
|
|
54
|
+
;(global as any).fetch = makeFetch(urls)
|
|
55
|
+
try {
|
|
56
|
+
const res = await handler({ ...baseConfig, action, oppId: 'OPP-legacy' }, {})
|
|
57
|
+
expect(res.success).toBe(true)
|
|
58
|
+
expect(urls).toEqual([urlPrefix + 'OPP-legacy'])
|
|
59
|
+
} finally {
|
|
60
|
+
delete (global as any).fetch
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
it('prefers opportunityId over a stale oppId when both are present', async () => {
|
|
66
|
+
const handler = evalHandler(integrationSalesforce.generateHandler())
|
|
67
|
+
const urls: string[] = []
|
|
68
|
+
;(global as any).fetch = makeFetch(urls)
|
|
69
|
+
try {
|
|
70
|
+
await handler(
|
|
71
|
+
{ ...baseConfig, action: 'get-opportunity', opportunityId: 'OPP-new', oppId: 'OPP-old' },
|
|
72
|
+
{}
|
|
73
|
+
)
|
|
74
|
+
expect(urls).toEqual([urlPrefix + 'OPP-new'])
|
|
75
|
+
} finally {
|
|
76
|
+
delete (global as any).fetch
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('create-opportunity is untouched: POSTs to the collection URL with no id', async () => {
|
|
81
|
+
const handler = evalHandler(integrationSalesforce.generateHandler())
|
|
82
|
+
const urls: string[] = []
|
|
83
|
+
;(global as any).fetch = makeFetch(urls)
|
|
84
|
+
try {
|
|
85
|
+
const res = await handler(
|
|
86
|
+
{ ...baseConfig, action: 'create-opportunity', name: 'Big deal', opportunityId: 'OPP-x' },
|
|
87
|
+
{}
|
|
88
|
+
)
|
|
89
|
+
expect(res.success).toBe(true)
|
|
90
|
+
// No trailing id segment — the opportunityId must not leak into the create URL.
|
|
91
|
+
expect(urls).toEqual(['https://my.salesforce.com/services/data/v58.0/sobjects/Opportunity'])
|
|
92
|
+
} finally {
|
|
93
|
+
delete (global as any).fetch
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('convert-lead keeps opportunityId in the body for the conversion target', async () => {
|
|
98
|
+
const handler = evalHandler(integrationSalesforce.generateHandler())
|
|
99
|
+
let sentBody: any
|
|
100
|
+
;(global as any).fetch = async (_url: string, init: any) => {
|
|
101
|
+
sentBody = JSON.parse(init.body)
|
|
102
|
+
return { ok: true, text: async () => '{}' }
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
await handler(
|
|
106
|
+
{
|
|
107
|
+
...baseConfig,
|
|
108
|
+
action: 'convert-lead',
|
|
109
|
+
leadId: 'LEAD-1',
|
|
110
|
+
convertedStatus: 'Closed - Converted',
|
|
111
|
+
opportunityId: 'OPP-target',
|
|
112
|
+
},
|
|
113
|
+
{}
|
|
114
|
+
)
|
|
115
|
+
// convert-lead reads opportunityId for the conversion target — its own
|
|
116
|
+
// semantics, unrelated to the CRUD-by-id fix.
|
|
117
|
+
expect(sentBody.opportunityId).toBe('OPP-target')
|
|
118
|
+
} finally {
|
|
119
|
+
delete (global as any).fetch
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
})
|