@teleporthq/teleport-plugin-next-workflows 0.43.59 → 0.43.61
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__/ai-chat-tool-dispatch-segments.test.ts +110 -0
- package/__tests__/ai-custom-prompt-json-mode.test.ts +78 -0
- package/__tests__/cart-get-total-storefront-tax.test.ts +5 -0
- package/__tests__/controlled-select-plugin.test.ts +170 -0
- package/__tests__/controlled-select.ts +215 -0
- package/dist/cjs/controlled-select.d.ts +23 -0
- package/dist/cjs/controlled-select.d.ts.map +1 -0
- package/dist/cjs/controlled-select.js +169 -0
- package/dist/cjs/controlled-select.js.map +1 -0
- package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/cjs/invoice/api-routes-code.js +19 -5
- package/dist/cjs/invoice/api-routes-code.js.map +1 -1
- package/dist/cjs/nodes/ai/ai-custom-prompt.d.ts.map +1 -1
- package/dist/cjs/nodes/ai/ai-custom-prompt.js +4 -1
- package/dist/cjs/nodes/ai/ai-custom-prompt.js.map +1 -1
- package/dist/cjs/nodes/cart/cart-add-item.d.ts.map +1 -1
- package/dist/cjs/nodes/cart/cart-add-item.js +18 -0
- package/dist/cjs/nodes/cart/cart-add-item.js.map +1 -1
- package/dist/cjs/nodes/cart/cart-clear.d.ts.map +1 -1
- package/dist/cjs/nodes/cart/cart-clear.js +4 -0
- package/dist/cjs/nodes/cart/cart-clear.js.map +1 -1
- package/dist/cjs/nodes/cart/cart-get-total.d.ts.map +1 -1
- package/dist/cjs/nodes/cart/cart-get-total.js +36 -0
- package/dist/cjs/nodes/cart/cart-get-total.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 +9 -2
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/esm/controlled-select.d.ts +23 -0
- package/dist/esm/controlled-select.d.ts.map +1 -0
- package/dist/esm/controlled-select.js +140 -0
- package/dist/esm/controlled-select.js.map +1 -0
- package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/esm/invoice/api-routes-code.js +19 -5
- package/dist/esm/invoice/api-routes-code.js.map +1 -1
- package/dist/esm/nodes/ai/ai-custom-prompt.d.ts.map +1 -1
- package/dist/esm/nodes/ai/ai-custom-prompt.js +4 -1
- package/dist/esm/nodes/ai/ai-custom-prompt.js.map +1 -1
- package/dist/esm/nodes/cart/cart-add-item.d.ts.map +1 -1
- package/dist/esm/nodes/cart/cart-add-item.js +18 -0
- package/dist/esm/nodes/cart/cart-add-item.js.map +1 -1
- package/dist/esm/nodes/cart/cart-clear.d.ts.map +1 -1
- package/dist/esm/nodes/cart/cart-clear.js +4 -0
- package/dist/esm/nodes/cart/cart-clear.js.map +1 -1
- package/dist/esm/nodes/cart/cart-get-total.d.ts.map +1 -1
- package/dist/esm/nodes/cart/cart-get-total.js +36 -0
- package/dist/esm/nodes/cart/cart-get-total.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 +10 -3
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/package.json +4 -4
- package/src/controlled-select.ts +167 -0
- package/src/invoice/api-routes-code.ts +19 -5
- package/src/nodes/ai/ai-custom-prompt.ts +4 -1
- package/src/nodes/cart/cart-add-item.ts +18 -0
- package/src/nodes/cart/cart-clear.ts +4 -0
- package/src/nodes/cart/cart-get-total.ts +35 -0
- package/src/workflow-component-plugin.ts +14 -3
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { splitIntoSegments } from '../src/segment-splitter'
|
|
2
|
+
import type { UIDLWorkflow, UIDLWorkflowNode, UIDLWorkflowEdge } from '@teleporthq/teleport-types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The AI Assistant Chat's action layer stands or falls on ONE property of the
|
|
6
|
+
* generated app: the tool must run BEFORE the answer is written, so the reply
|
|
7
|
+
* can report truthfully what happened.
|
|
8
|
+
*
|
|
9
|
+
* The tool runs inside a `general-custom-node`, which the splitter pins to the
|
|
10
|
+
* CLIENT, while both AI calls are SERVER nodes. So the walk has to produce
|
|
11
|
+
* `[server: decide] [client: run] [server: answer]`.
|
|
12
|
+
*
|
|
13
|
+
* The chat used to express this with two plain edges out of a parse node — one
|
|
14
|
+
* into the tool chain, one into the answer — and relied on the answer's PROMPT
|
|
15
|
+
* referencing the tool's summary to force the ordering, because
|
|
16
|
+
* `getTopologicalOrder` turns a `workflowContext` reference into a dependency
|
|
17
|
+
* edge. That worked, but only by accident of the data flow: remove the
|
|
18
|
+
* reference and the answer was written before the tool ran.
|
|
19
|
+
*
|
|
20
|
+
* The dispatch now lives inside its own custom node, so the ordering comes from
|
|
21
|
+
* a real EDGE. This test pins both halves: the three-segment shape, and that it
|
|
22
|
+
* survives even with no prompt reference at all.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const ctx = (nodeId: string, path: string[]) => ({ type: 'workflowContext', nodeId, path })
|
|
26
|
+
|
|
27
|
+
const node = (
|
|
28
|
+
id: string,
|
|
29
|
+
type: string,
|
|
30
|
+
stepNumber: number,
|
|
31
|
+
config: Record<string, unknown> = {}
|
|
32
|
+
): UIDLWorkflowNode => ({ id, type, label: id, config, stepNumber } as unknown as UIDLWorkflowNode)
|
|
33
|
+
|
|
34
|
+
const edge = (source: string, target: string, sourceHandle?: string): UIDLWorkflowEdge =>
|
|
35
|
+
({ id: `${source}->${target}`, source, target, sourceHandle } as unknown as UIDLWorkflowEdge)
|
|
36
|
+
|
|
37
|
+
/** The chat's shape from the merge script onward. */
|
|
38
|
+
function buildChatWorkflow(options: { answerReferencesOutcome: boolean }): UIDLWorkflow {
|
|
39
|
+
const nodes: UIDLWorkflowNode[] = [
|
|
40
|
+
node('merge', 'general-custom-js', 1, { context: 'server', code: 'x' }),
|
|
41
|
+
node('decide', 'ai-custom-prompt', 2, {
|
|
42
|
+
jsonMode: true,
|
|
43
|
+
prompt: ['ctx:', ctx('merge', ['merge', 'context'])],
|
|
44
|
+
}),
|
|
45
|
+
node('run', 'general-custom-node', 3, {
|
|
46
|
+
customNodeId: 'custom_run_action',
|
|
47
|
+
parameters: [{ key: 'decisionText', value: ctx('decide', ['decide', 'response']) }],
|
|
48
|
+
}),
|
|
49
|
+
node('answer', 'ai-custom-prompt', 4, {
|
|
50
|
+
streaming: true,
|
|
51
|
+
prompt: [
|
|
52
|
+
'Context:',
|
|
53
|
+
ctx('merge', ['merge', 'context']),
|
|
54
|
+
...(options.answerReferencesOutcome
|
|
55
|
+
? ['\nOutcome: ', ctx('run', ['run', 'outcomeText'])]
|
|
56
|
+
: []),
|
|
57
|
+
],
|
|
58
|
+
}),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
const edges: UIDLWorkflowEdge[] = [
|
|
62
|
+
edge('merge', 'decide'),
|
|
63
|
+
edge('decide', 'run'),
|
|
64
|
+
edge('run', 'answer'),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
return { name: 'send-chat-message', nodes, edges, trigger: { type: 'manual' } } as UIDLWorkflow
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const segmentOf = (segments: ReturnType<typeof splitIntoSegments>, nodeId: string): number =>
|
|
71
|
+
segments.findIndex((segment) => segment.nodes.some((entry) => entry.id === nodeId))
|
|
72
|
+
|
|
73
|
+
describe('AI chat tool dispatch — segment ordering', () => {
|
|
74
|
+
const segments = splitIntoSegments(buildChatWorkflow({ answerReferencesOutcome: true }))
|
|
75
|
+
|
|
76
|
+
it('runs the action on the CLIENT, where general-custom-node is dispatched', () => {
|
|
77
|
+
// The server handler for general-custom-node is a stub: placed in a server
|
|
78
|
+
// segment the nested custom node never runs and downstream references
|
|
79
|
+
// resolve to the raw marker object.
|
|
80
|
+
expect(segments[segmentOf(segments, 'run')].env).toBe('client')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('keeps both AI calls on the server', () => {
|
|
84
|
+
expect(segments[segmentOf(segments, 'decide')].env).toBe('server')
|
|
85
|
+
expect(segments[segmentOf(segments, 'answer')].env).toBe('server')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('orders the answer AFTER the action', () => {
|
|
89
|
+
const answerSegment = segmentOf(segments, 'answer')
|
|
90
|
+
expect(answerSegment).toBeGreaterThan(segmentOf(segments, 'decide'))
|
|
91
|
+
expect(answerSegment).toBeGreaterThan(segmentOf(segments, 'run'))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('produces exactly the three-segment shape the design depends on', () => {
|
|
95
|
+
expect(segments.map((segment) => segment.env)).toEqual(['server', 'client', 'server'])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('holds the ordering on the EDGE, not on the prompt reference', () => {
|
|
99
|
+
// The regression this replaces: with the old two-edges-out-of-parse shape,
|
|
100
|
+
// dropping the prompt reference let the answer overtake the tool. A real
|
|
101
|
+
// edge cannot be overtaken, so the shape is identical either way.
|
|
102
|
+
const withoutReference = splitIntoSegments(
|
|
103
|
+
buildChatWorkflow({ answerReferencesOutcome: false })
|
|
104
|
+
)
|
|
105
|
+
expect(withoutReference.map((segment) => segment.env)).toEqual(['server', 'client', 'server'])
|
|
106
|
+
expect(segmentOf(withoutReference, 'answer')).toBeGreaterThan(
|
|
107
|
+
segmentOf(withoutReference, 'run')
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { aiCustomPrompt } from '../src/nodes/ai/ai-custom-prompt'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `ai-custom-prompt` used to hard-code `jsonMode: false`, so a node whose only
|
|
5
|
+
* job is to emit a machine-readable decision (the AI Assistant Chat's tool
|
|
6
|
+
* router) had no way to ask the provider for a JSON-only reply. The flag is now
|
|
7
|
+
* forwarded from the node config.
|
|
8
|
+
*
|
|
9
|
+
* The generated provider utils are emitted as
|
|
10
|
+
* `var __ai_callProvider = typeof __ai_callProvider !== 'undefined' ? … : <def>`,
|
|
11
|
+
* so defining the name ahead of the handler source substitutes a spy for the
|
|
12
|
+
* real provider call.
|
|
13
|
+
*/
|
|
14
|
+
type ProviderCall = { jsonMode?: boolean; systemMessage?: string; userMessage?: string }
|
|
15
|
+
type PromptHandler = (
|
|
16
|
+
config: Record<string, unknown>,
|
|
17
|
+
context: Record<string, unknown>,
|
|
18
|
+
streamCallback?: unknown
|
|
19
|
+
) => Promise<Record<string, unknown>>
|
|
20
|
+
|
|
21
|
+
function loadHandlerWithProviderSpy(): { handler: PromptHandler; calls: ProviderCall[] } {
|
|
22
|
+
const calls: ProviderCall[] = []
|
|
23
|
+
const source = aiCustomPrompt.generateHandler()
|
|
24
|
+
// eslint-disable-next-line no-new-func
|
|
25
|
+
const factory = new Function(
|
|
26
|
+
'__calls',
|
|
27
|
+
[
|
|
28
|
+
'var __ai_callProvider = function (params) {',
|
|
29
|
+
' __calls.push(params);',
|
|
30
|
+
' return Promise.resolve({ content: \'{"action":"none"}\', usage: { total_tokens: 3 } });',
|
|
31
|
+
'};',
|
|
32
|
+
source,
|
|
33
|
+
'return ai_custom_prompt;',
|
|
34
|
+
].join('\n')
|
|
35
|
+
)
|
|
36
|
+
return { handler: factory(calls) as PromptHandler, calls }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('ai-custom-prompt jsonMode passthrough', () => {
|
|
40
|
+
it('asks the provider for JSON when the node config sets jsonMode', async () => {
|
|
41
|
+
const { handler, calls } = loadHandlerWithProviderSpy()
|
|
42
|
+
|
|
43
|
+
const result = await handler({ prompt: 'Decide.', token: 'sk-test', jsonMode: true }, {})
|
|
44
|
+
|
|
45
|
+
expect(calls).toHaveLength(1)
|
|
46
|
+
expect(calls[0].jsonMode).toBe(true)
|
|
47
|
+
expect(result.response).toBe('{"action":"none"}')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('leaves JSON mode off for an ordinary prompt node', async () => {
|
|
51
|
+
const { handler, calls } = loadHandlerWithProviderSpy()
|
|
52
|
+
|
|
53
|
+
await handler({ prompt: 'Write a haiku.', token: 'sk-test' }, {})
|
|
54
|
+
|
|
55
|
+
expect(calls[0].jsonMode).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('treats any non-true value as off, so a stale string config cannot enable it', async () => {
|
|
59
|
+
const { handler, calls } = loadHandlerWithProviderSpy()
|
|
60
|
+
|
|
61
|
+
await handler({ prompt: 'Hi.', token: 'sk-test', jsonMode: 'true' }, {})
|
|
62
|
+
|
|
63
|
+
expect(calls[0].jsonMode).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('never sends jsonMode down the streaming path', async () => {
|
|
67
|
+
const { handler, calls } = loadHandlerWithProviderSpy()
|
|
68
|
+
const chunks: string[] = []
|
|
69
|
+
|
|
70
|
+
// No stream callback => the streaming branch is skipped; with one, the
|
|
71
|
+
// handler must not reach __ai_callProvider at all.
|
|
72
|
+
await handler({ prompt: 'Hi.', token: 'sk-test', jsonMode: true, streaming: true }, {}, () =>
|
|
73
|
+
chunks.push('x')
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect(calls).toHaveLength(0)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -158,8 +158,13 @@ describe('cart-get-total — storefront tax', () => {
|
|
|
158
158
|
tax: 0,
|
|
159
159
|
total: 0,
|
|
160
160
|
itemCount: 0,
|
|
161
|
+
// No readable cart means no per-product markdown to record on the order.
|
|
162
|
+
productDiscountTotal: 0,
|
|
161
163
|
deliveryConfig: null,
|
|
162
164
|
taxConfig: { storefrontTaxRate: 0 },
|
|
165
|
+
// Unreadable storage means no applied voucher, which the place-order
|
|
166
|
+
// workflow must see as "nothing to re-validate" rather than as absent.
|
|
167
|
+
voucher: null,
|
|
163
168
|
})
|
|
164
169
|
})
|
|
165
170
|
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { parse } from '@babel/parser'
|
|
2
|
+
import generate from '@babel/generator'
|
|
3
|
+
import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The unit tests in `controlled-select.ts` pin the rule; these run the real
|
|
7
|
+
* plugin, because the defect was never in the rule — it was in WHERE the
|
|
8
|
+
* decision got made.
|
|
9
|
+
*
|
|
10
|
+
* `makeControlUncontrolledWhenNoChangeHandler` demotes `value` → `defaultValue`
|
|
11
|
+
* on a form control with no change handler, while the JSX is generated. Element
|
|
12
|
+
* triggers are attached afterwards, by this plugin. So a `<select>` whose only
|
|
13
|
+
* handler is a workflow was always demoted, and `defaultValue` is read once at
|
|
14
|
+
* mount — state established after hydration (a `?sortBy=` deep link) never
|
|
15
|
+
* reached the DOM. The products list shipped sorted correctly under a dropdown
|
|
16
|
+
* reading "Name (A-Z)".
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const changeWorkflow = (elementHtmlId: string, property: string) => ({
|
|
20
|
+
id: `wf-${property}`,
|
|
21
|
+
name: 'Sort changed',
|
|
22
|
+
trigger: {
|
|
23
|
+
type: 'event-element-event',
|
|
24
|
+
nodeId: 'trigger-change',
|
|
25
|
+
scope: 'element',
|
|
26
|
+
config: { elementHtmlId, eventType: 'change' },
|
|
27
|
+
},
|
|
28
|
+
nodes: [
|
|
29
|
+
{
|
|
30
|
+
id: 'update-1',
|
|
31
|
+
type: 'state-update-local-state',
|
|
32
|
+
config: {
|
|
33
|
+
property,
|
|
34
|
+
value: {
|
|
35
|
+
type: 'workflowContext',
|
|
36
|
+
nodeId: 'trigger-change',
|
|
37
|
+
path: ['trigger-change', 'value'],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
stepNumber: 1,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
edges: [{ id: 'e1', source: 'trigger-change', target: 'update-1' }],
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const runPlugin = async (
|
|
47
|
+
componentSource: string,
|
|
48
|
+
workflows: Record<string, unknown>
|
|
49
|
+
): Promise<string> => {
|
|
50
|
+
const ast = parse(componentSource, { plugins: ['jsx'] })
|
|
51
|
+
const chunk = {
|
|
52
|
+
type: 'chunk-type-ast',
|
|
53
|
+
name: 'jsx-component',
|
|
54
|
+
content: ast.program.body[0],
|
|
55
|
+
}
|
|
56
|
+
const structure: any = {
|
|
57
|
+
uidl: {
|
|
58
|
+
name: 'ProductsList',
|
|
59
|
+
outputOptions: { pageId: 'page-products', fileName: 'products' },
|
|
60
|
+
stateDefinitions: {},
|
|
61
|
+
node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
|
|
62
|
+
},
|
|
63
|
+
chunks: [chunk],
|
|
64
|
+
options: { workflows: { workflows, customNodes: {} } },
|
|
65
|
+
dependencies: {},
|
|
66
|
+
}
|
|
67
|
+
await createNextWorkflowPlugin({ isPage: true })(structure)
|
|
68
|
+
return generate(chunk.content as any).code
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const SORT_SELECT = (extraAttrs = '') => `const ProductsList = (props) => {
|
|
72
|
+
return (
|
|
73
|
+
<select id="thq_sort" ${extraAttrs} defaultValue={sortBy}>
|
|
74
|
+
<option value="name-asc">Name (A-Z)</option>
|
|
75
|
+
<option value="price-asc">Price (low to high)</option>
|
|
76
|
+
</select>
|
|
77
|
+
)
|
|
78
|
+
}`
|
|
79
|
+
|
|
80
|
+
describe('a select made controlled by its workflow', () => {
|
|
81
|
+
it('ships `value=`, not `defaultValue=`', async () => {
|
|
82
|
+
const code = await runPlugin(SORT_SELECT(), {
|
|
83
|
+
'wf-sortBy': changeWorkflow('thq_sort', 'sortBy'),
|
|
84
|
+
})
|
|
85
|
+
expect(code).toContain('value={sortBy}')
|
|
86
|
+
expect(code).not.toContain('defaultValue={sortBy}')
|
|
87
|
+
expect(code).toContain(`__wfRef.current.elementTriggers["thq_sort"]["onChange"]`)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('keeps the options and the rest of the element intact', async () => {
|
|
91
|
+
const code = await runPlugin(SORT_SELECT(), {
|
|
92
|
+
'wf-sortBy': changeWorkflow('thq_sort', 'sortBy'),
|
|
93
|
+
})
|
|
94
|
+
expect(code).toContain('<option value="name-asc">')
|
|
95
|
+
expect(code).toContain('<option value="price-asc">')
|
|
96
|
+
expect(code).toContain('id="thq_sort"')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('does not end up with both props when the JSX was already controlled', async () => {
|
|
100
|
+
const code = await runPlugin(
|
|
101
|
+
`const ProductsList = (props) => {
|
|
102
|
+
return <select id="thq_sort" value={sortBy} defaultValue={sortBy} />
|
|
103
|
+
}`,
|
|
104
|
+
{ 'wf-sortBy': changeWorkflow('thq_sort', 'sortBy') }
|
|
105
|
+
)
|
|
106
|
+
expect(code).toContain('value={sortBy}')
|
|
107
|
+
expect(code).not.toContain('defaultValue')
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('what the plugin still leaves uncontrolled', () => {
|
|
112
|
+
it('a select whose workflow writes some OTHER state', async () => {
|
|
113
|
+
// Controlling it would freeze the dropdown — nothing moves `sortBy`, so
|
|
114
|
+
// every selection would snap straight back.
|
|
115
|
+
const code = await runPlugin(SORT_SELECT(), {
|
|
116
|
+
'wf-other': changeWorkflow('thq_sort', 'somethingElse'),
|
|
117
|
+
})
|
|
118
|
+
expect(code).toContain('defaultValue={sortBy}')
|
|
119
|
+
expect(code).not.toContain('value={sortBy}')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('a select bound to an entity field rather than a state name', async () => {
|
|
123
|
+
const code = await runPlugin(
|
|
124
|
+
`const EditForm = (props) => {
|
|
125
|
+
return <select id="thq_sort" defaultValue={props.item?.category} />
|
|
126
|
+
}`,
|
|
127
|
+
{ 'wf-category': changeWorkflow('thq_sort', 'category') }
|
|
128
|
+
)
|
|
129
|
+
expect(code).toContain('defaultValue={props.item?.category}')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('a text input, even when its workflow writes the bound state', async () => {
|
|
133
|
+
// An input needs a SYNCHRONOUS write-back per keystroke; a workflow is an
|
|
134
|
+
// async pipeline, and a controlled input whose state lags drops characters
|
|
135
|
+
// and jumps the caret.
|
|
136
|
+
const code = await runPlugin(
|
|
137
|
+
`const Chat = (props) => {
|
|
138
|
+
return <input type="text" id="thq_msg" defaultValue={chatInputValue} />
|
|
139
|
+
}`,
|
|
140
|
+
{ 'wf-chat': changeWorkflow('thq_msg', 'chatInputValue') }
|
|
141
|
+
)
|
|
142
|
+
expect(code).toContain('defaultValue={chatInputValue}')
|
|
143
|
+
expect(code).not.toContain('value={chatInputValue}')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('a checkbox — a toggle that bounces back reads as broken', async () => {
|
|
147
|
+
const code = await runPlugin(
|
|
148
|
+
`const Form = (props) => {
|
|
149
|
+
return <input type="checkbox" id="thq_agree" defaultChecked={agreed} />
|
|
150
|
+
}`,
|
|
151
|
+
{ 'wf-agree': changeWorkflow('thq_agree', 'agreed') }
|
|
152
|
+
)
|
|
153
|
+
expect(code).toContain('defaultChecked={agreed}')
|
|
154
|
+
expect(code).not.toContain('checked={agreed}')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('a select whose workflow is triggered by a CLICK, not a change', async () => {
|
|
158
|
+
const clickWorkflow = {
|
|
159
|
+
...changeWorkflow('thq_sort', 'sortBy'),
|
|
160
|
+
trigger: {
|
|
161
|
+
type: 'event-element-clicked',
|
|
162
|
+
nodeId: 'trigger-click',
|
|
163
|
+
scope: 'element',
|
|
164
|
+
config: { elementHtmlId: 'thq_sort', eventType: 'click' },
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
const code = await runPlugin(SORT_SELECT(), { 'wf-click': clickWorkflow })
|
|
168
|
+
expect(code).toContain('defaultValue={sortBy}')
|
|
169
|
+
})
|
|
170
|
+
})
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { parse } from '@babel/parser'
|
|
2
|
+
import * as types from '@babel/types'
|
|
3
|
+
import { UIDLWorkflow } from '@teleporthq/teleport-types'
|
|
4
|
+
import {
|
|
5
|
+
collectWrittenStateProperties,
|
|
6
|
+
readBoundStateName,
|
|
7
|
+
restoreControlledSelectValue,
|
|
8
|
+
} from '../src/controlled-select'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A `<select>` whose only change handler comes from a workflow was shipping as
|
|
12
|
+
* UNCONTROLLED (`defaultValue`), because the pass that demotes handler-less form
|
|
13
|
+
* controls runs while the JSX is generated and workflow triggers are attached
|
|
14
|
+
* afterwards. `defaultValue` is read once at mount, so state established after
|
|
15
|
+
* hydration never reached the DOM: `/products?sortBy=price-asc` sorted the
|
|
16
|
+
* products correctly under a dropdown still reading "Name (A-Z)".
|
|
17
|
+
*
|
|
18
|
+
* The restore is deliberately narrow, and most of what follows pins what it
|
|
19
|
+
* refuses to touch — a control made controlled without its handler writing the
|
|
20
|
+
* bound state is FROZEN, which is a worse defect than the stale label.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const openingElementOf = (source: string): types.JSXOpeningElement => {
|
|
24
|
+
const file = parse(`const x = ${source}`, { sourceType: 'module', plugins: ['jsx'] })
|
|
25
|
+
let found: types.JSXOpeningElement | null = null
|
|
26
|
+
const walk = (node: unknown): void => {
|
|
27
|
+
if (!node || typeof node !== 'object') return
|
|
28
|
+
if ((node as types.Node).type === 'JSXOpeningElement' && !found) {
|
|
29
|
+
found = node as types.JSXOpeningElement
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
for (const value of Object.values(node as Record<string, unknown>)) {
|
|
33
|
+
if (Array.isArray(value)) value.forEach(walk)
|
|
34
|
+
else if (value && typeof value === 'object') walk(value)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
walk(file.program)
|
|
38
|
+
if (!found) throw new Error('no JSX opening element in: ' + source)
|
|
39
|
+
return found
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const attrNames = (element: types.JSXOpeningElement): string[] =>
|
|
43
|
+
element.attributes
|
|
44
|
+
.filter((attr): attr is types.JSXAttribute => types.isJSXAttribute(attr))
|
|
45
|
+
.map((attr) => (types.isJSXIdentifier(attr.name) ? attr.name.name : ''))
|
|
46
|
+
|
|
47
|
+
const workflowWriting = (...properties: string[]): UIDLWorkflow =>
|
|
48
|
+
({
|
|
49
|
+
id: 'wf',
|
|
50
|
+
name: 'Sort changed',
|
|
51
|
+
trigger: { type: 'event-element-event', config: {} },
|
|
52
|
+
nodes: properties.map((property, index) => ({
|
|
53
|
+
id: `n${index}`,
|
|
54
|
+
type: 'state-update-local-state',
|
|
55
|
+
config: { property },
|
|
56
|
+
})),
|
|
57
|
+
edges: [],
|
|
58
|
+
usedInNodes: {},
|
|
59
|
+
} as unknown as UIDLWorkflow)
|
|
60
|
+
|
|
61
|
+
const SORT_SELECT = `<select defaultValue={sortBy} onChange={__wfRef.current.elementTriggers['a']['onChange']} />`
|
|
62
|
+
|
|
63
|
+
describe('collectWrittenStateProperties', () => {
|
|
64
|
+
it('reads a local and a global state write', () => {
|
|
65
|
+
const workflow = {
|
|
66
|
+
nodes: [
|
|
67
|
+
{ id: '1', type: 'state-update-local-state', config: { property: 'sortBy' } },
|
|
68
|
+
{ id: '2', type: 'state-update-global-state', config: { property: 'cartCount' } },
|
|
69
|
+
],
|
|
70
|
+
} as unknown as UIDLWorkflow
|
|
71
|
+
expect(collectWrittenStateProperties(workflow)).toEqual(new Set(['sortBy', 'cartCount']))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('reads a batch update, whose targets live under `updates[].key`', () => {
|
|
75
|
+
const workflow = {
|
|
76
|
+
nodes: [
|
|
77
|
+
{
|
|
78
|
+
id: '1',
|
|
79
|
+
type: 'state-batch-update',
|
|
80
|
+
config: { updates: [{ key: 'sortBy' }, { key: 'page' }] },
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
} as unknown as UIDLWorkflow
|
|
84
|
+
expect(collectWrittenStateProperties(workflow)).toEqual(new Set(['sortBy', 'page']))
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('ignores nodes that do not write state, and survives a malformed graph', () => {
|
|
88
|
+
expect(
|
|
89
|
+
collectWrittenStateProperties({
|
|
90
|
+
nodes: [
|
|
91
|
+
{ id: '1', type: 'general-if-statement', config: { property: 'sortBy' } },
|
|
92
|
+
{ id: '2', type: 'state-update-local-state', config: {} },
|
|
93
|
+
null,
|
|
94
|
+
],
|
|
95
|
+
} as unknown as UIDLWorkflow)
|
|
96
|
+
).toEqual(new Set())
|
|
97
|
+
expect(collectWrittenStateProperties({} as UIDLWorkflow)).toEqual(new Set())
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('readBoundStateName', () => {
|
|
102
|
+
it('reads a bare identifier', () => {
|
|
103
|
+
const element = openingElementOf('<select defaultValue={sortBy} />')
|
|
104
|
+
expect(readBoundStateName(element.attributes[0] as types.JSXAttribute)).toBe('sortBy')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('refuses a member expression — an entity field has no state behind it', () => {
|
|
108
|
+
const element = openingElementOf('<select defaultValue={props.item?.category} />')
|
|
109
|
+
expect(readBoundStateName(element.attributes[0] as types.JSXAttribute)).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('refuses a string literal', () => {
|
|
113
|
+
const element = openingElementOf('<select defaultValue="name-asc" />')
|
|
114
|
+
expect(readBoundStateName(element.attributes[0] as types.JSXAttribute)).toBeNull()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('restoreControlledSelectValue', () => {
|
|
119
|
+
it('controls a select whose workflow writes the state it is bound to', () => {
|
|
120
|
+
const element = openingElementOf(SORT_SELECT)
|
|
121
|
+
restoreControlledSelectValue(element, [workflowWriting('sortBy')])
|
|
122
|
+
expect(attrNames(element)).toEqual(['value', 'onChange'])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('keeps the binding expression untouched when it renames the prop', () => {
|
|
126
|
+
const element = openingElementOf(SORT_SELECT)
|
|
127
|
+
restoreControlledSelectValue(element, [workflowWriting('sortBy')])
|
|
128
|
+
const value = element.attributes[0] as types.JSXAttribute
|
|
129
|
+
expect(types.isJSXExpressionContainer(value.value)).toBe(true)
|
|
130
|
+
expect(readBoundStateName(value)).toBe('sortBy')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('is idempotent', () => {
|
|
134
|
+
const element = openingElementOf(SORT_SELECT)
|
|
135
|
+
restoreControlledSelectValue(element, [workflowWriting('sortBy')])
|
|
136
|
+
restoreControlledSelectValue(element, [workflowWriting('sortBy')])
|
|
137
|
+
expect(attrNames(element)).toEqual(['value', 'onChange'])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('matches across several workflows on the same onChange', () => {
|
|
141
|
+
const element = openingElementOf(SORT_SELECT)
|
|
142
|
+
restoreControlledSelectValue(element, [workflowWriting('unrelated'), workflowWriting('sortBy')])
|
|
143
|
+
expect(attrNames(element)).toContain('value')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('restoreControlledSelectValue — what it refuses to touch', () => {
|
|
148
|
+
it('leaves a text input uncontrolled even when its workflow writes the state', () => {
|
|
149
|
+
// An input accumulates one keystroke at a time and needs a SYNCHRONOUS
|
|
150
|
+
// write-back per keystroke; a workflow is async, and a controlled input
|
|
151
|
+
// whose state lags drops characters and jumps the caret.
|
|
152
|
+
const element = openingElementOf(
|
|
153
|
+
`<input type="text" defaultValue={chatInputValue} onChange={h} />`
|
|
154
|
+
)
|
|
155
|
+
restoreControlledSelectValue(element, [workflowWriting('chatInputValue')])
|
|
156
|
+
expect(attrNames(element)).toContain('defaultValue')
|
|
157
|
+
expect(attrNames(element)).not.toContain('value')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('leaves a textarea uncontrolled', () => {
|
|
161
|
+
const element = openingElementOf(`<textarea defaultValue={draft} onChange={h} />`)
|
|
162
|
+
restoreControlledSelectValue(element, [workflowWriting('draft')])
|
|
163
|
+
expect(attrNames(element)).toContain('defaultValue')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('leaves a checkbox alone — a toggle that bounces back reads as broken', () => {
|
|
167
|
+
const element = openingElementOf(
|
|
168
|
+
`<input type="checkbox" defaultChecked={agreed} onChange={h} />`
|
|
169
|
+
)
|
|
170
|
+
restoreControlledSelectValue(element, [workflowWriting('agreed')])
|
|
171
|
+
expect(attrNames(element)).toEqual(['type', 'defaultChecked', 'onChange'])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('leaves a select whose workflow writes some OTHER state uncontrolled', () => {
|
|
175
|
+
// Controlling it would freeze the dropdown: nothing would ever move the
|
|
176
|
+
// bound state, so every selection would snap back.
|
|
177
|
+
const element = openingElementOf(SORT_SELECT)
|
|
178
|
+
restoreControlledSelectValue(element, [workflowWriting('somethingElse')])
|
|
179
|
+
expect(attrNames(element)).toEqual(['defaultValue', 'onChange'])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('leaves a select bound to an entity field uncontrolled', () => {
|
|
183
|
+
const element = openingElementOf(`<select defaultValue={props.item?.category} onChange={h} />`)
|
|
184
|
+
restoreControlledSelectValue(element, [workflowWriting('category')])
|
|
185
|
+
expect(attrNames(element)).toEqual(['defaultValue', 'onChange'])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('leaves a select with no change handler uncontrolled', () => {
|
|
189
|
+
// This is the case the demotion exists for: React freezes a controlled
|
|
190
|
+
// control that has no way to change.
|
|
191
|
+
const element = openingElementOf(`<select defaultValue={sortBy} />`)
|
|
192
|
+
restoreControlledSelectValue(element, [workflowWriting('sortBy')])
|
|
193
|
+
expect(attrNames(element)).toEqual(['defaultValue'])
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('leaves a select with no workflows on its onChange uncontrolled', () => {
|
|
197
|
+
const element = openingElementOf(SORT_SELECT)
|
|
198
|
+
restoreControlledSelectValue(element, [])
|
|
199
|
+
expect(attrNames(element)).toEqual(['defaultValue', 'onChange'])
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('leaves a capitalised <Select> component alone', () => {
|
|
203
|
+
const element = openingElementOf(`<Select defaultValue={sortBy} onChange={h} />`)
|
|
204
|
+
restoreControlledSelectValue(element, [workflowWriting('sortBy')])
|
|
205
|
+
expect(attrNames(element)).toEqual(['defaultValue', 'onChange'])
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('drops a stale defaultValue rather than shipping both props', () => {
|
|
209
|
+
// React warns about a control carrying `value` and `defaultValue` together
|
|
210
|
+
// and resolves it unpredictably.
|
|
211
|
+
const element = openingElementOf(`<select value={sortBy} defaultValue={sortBy} onChange={h} />`)
|
|
212
|
+
restoreControlledSelectValue(element, [workflowWriting('sortBy')])
|
|
213
|
+
expect(attrNames(element)).toEqual(['value', 'onChange'])
|
|
214
|
+
})
|
|
215
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as types from '@babel/types';
|
|
2
|
+
import { UIDLWorkflow } from '@teleporthq/teleport-types';
|
|
3
|
+
/** Every state property a workflow's graph writes. */
|
|
4
|
+
export declare const collectWrittenStateProperties: (workflow: UIDLWorkflow) => Set<string>;
|
|
5
|
+
/**
|
|
6
|
+
* The state name a `defaultValue` binding reads, or `null` when it is not a
|
|
7
|
+
* plain state reference.
|
|
8
|
+
*
|
|
9
|
+
* Only a bare identifier counts. `defaultValue={props.item?.category}` is an
|
|
10
|
+
* entity column with no state mirror — the very case the demotion exists for —
|
|
11
|
+
* and a member expression can never be matched against a state-update target
|
|
12
|
+
* with any confidence.
|
|
13
|
+
*/
|
|
14
|
+
export declare const readBoundStateName: (attr: types.JSXAttribute) => string | null;
|
|
15
|
+
/**
|
|
16
|
+
* Turns `defaultValue` back into `value` on a `<select>` that a workflow makes
|
|
17
|
+
* genuinely controlled. Mutates the opening element in place; idempotent, and a
|
|
18
|
+
* no-op for every element that does not meet BOTH conditions above.
|
|
19
|
+
*
|
|
20
|
+
* @param workflows The workflows wired to this element's `onChange`.
|
|
21
|
+
*/
|
|
22
|
+
export declare const restoreControlledSelectValue: (openingElement: types.JSXOpeningElement, workflows: UIDLWorkflow[]) => void;
|
|
23
|
+
//# sourceMappingURL=controlled-select.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"controlled-select.d.ts","sourceRoot":"","sources":["../../src/controlled-select.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,cAAc,CAAA;AACrC,OAAO,EAAE,YAAY,EAAoB,MAAM,4BAA4B,CAAA;AA4E3E,sDAAsD;AACtD,eAAO,MAAM,6BAA6B,aAAc,YAAY,KAAG,IAAI,MAAM,CA0BhF,CAAA;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,SAAU,MAAM,YAAY,KAAG,MAAM,GAAG,IAMtE,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,mBACvB,MAAM,iBAAiB,aAC5B,YAAY,EAAE,KACxB,IAiCF,CAAA"}
|