@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.
Files changed (59) hide show
  1. package/__tests__/ai-chat-tool-dispatch-segments.test.ts +110 -0
  2. package/__tests__/ai-custom-prompt-json-mode.test.ts +78 -0
  3. package/__tests__/cart-get-total-storefront-tax.test.ts +5 -0
  4. package/__tests__/controlled-select-plugin.test.ts +170 -0
  5. package/__tests__/controlled-select.ts +215 -0
  6. package/dist/cjs/controlled-select.d.ts +23 -0
  7. package/dist/cjs/controlled-select.d.ts.map +1 -0
  8. package/dist/cjs/controlled-select.js +169 -0
  9. package/dist/cjs/controlled-select.js.map +1 -0
  10. package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
  11. package/dist/cjs/invoice/api-routes-code.js +19 -5
  12. package/dist/cjs/invoice/api-routes-code.js.map +1 -1
  13. package/dist/cjs/nodes/ai/ai-custom-prompt.d.ts.map +1 -1
  14. package/dist/cjs/nodes/ai/ai-custom-prompt.js +4 -1
  15. package/dist/cjs/nodes/ai/ai-custom-prompt.js.map +1 -1
  16. package/dist/cjs/nodes/cart/cart-add-item.d.ts.map +1 -1
  17. package/dist/cjs/nodes/cart/cart-add-item.js +18 -0
  18. package/dist/cjs/nodes/cart/cart-add-item.js.map +1 -1
  19. package/dist/cjs/nodes/cart/cart-clear.d.ts.map +1 -1
  20. package/dist/cjs/nodes/cart/cart-clear.js +4 -0
  21. package/dist/cjs/nodes/cart/cart-clear.js.map +1 -1
  22. package/dist/cjs/nodes/cart/cart-get-total.d.ts.map +1 -1
  23. package/dist/cjs/nodes/cart/cart-get-total.js +36 -0
  24. package/dist/cjs/nodes/cart/cart-get-total.js.map +1 -1
  25. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  26. package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
  27. package/dist/cjs/workflow-component-plugin.js +9 -2
  28. package/dist/cjs/workflow-component-plugin.js.map +1 -1
  29. package/dist/esm/controlled-select.d.ts +23 -0
  30. package/dist/esm/controlled-select.d.ts.map +1 -0
  31. package/dist/esm/controlled-select.js +140 -0
  32. package/dist/esm/controlled-select.js.map +1 -0
  33. package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
  34. package/dist/esm/invoice/api-routes-code.js +19 -5
  35. package/dist/esm/invoice/api-routes-code.js.map +1 -1
  36. package/dist/esm/nodes/ai/ai-custom-prompt.d.ts.map +1 -1
  37. package/dist/esm/nodes/ai/ai-custom-prompt.js +4 -1
  38. package/dist/esm/nodes/ai/ai-custom-prompt.js.map +1 -1
  39. package/dist/esm/nodes/cart/cart-add-item.d.ts.map +1 -1
  40. package/dist/esm/nodes/cart/cart-add-item.js +18 -0
  41. package/dist/esm/nodes/cart/cart-add-item.js.map +1 -1
  42. package/dist/esm/nodes/cart/cart-clear.d.ts.map +1 -1
  43. package/dist/esm/nodes/cart/cart-clear.js +4 -0
  44. package/dist/esm/nodes/cart/cart-clear.js.map +1 -1
  45. package/dist/esm/nodes/cart/cart-get-total.d.ts.map +1 -1
  46. package/dist/esm/nodes/cart/cart-get-total.js +36 -0
  47. package/dist/esm/nodes/cart/cart-get-total.js.map +1 -1
  48. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  49. package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
  50. package/dist/esm/workflow-component-plugin.js +10 -3
  51. package/dist/esm/workflow-component-plugin.js.map +1 -1
  52. package/package.json +4 -4
  53. package/src/controlled-select.ts +167 -0
  54. package/src/invoice/api-routes-code.ts +19 -5
  55. package/src/nodes/ai/ai-custom-prompt.ts +4 -1
  56. package/src/nodes/cart/cart-add-item.ts +18 -0
  57. package/src/nodes/cart/cart-clear.ts +4 -0
  58. package/src/nodes/cart/cart-get-total.ts +35 -0
  59. 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"}