@teleporthq/teleport-plugin-next-workflows 0.45.9 → 0.45.14
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__/component-scope-interval.test.ts +120 -0
- package/__tests__/element-trigger-context-fields.test.ts +279 -0
- package/__tests__/global-state-read-only-wiring.test.ts +257 -0
- package/__tests__/interval-control-event.test.ts +217 -0
- package/dist/cjs/trigger-generator.d.ts +2 -0
- package/dist/cjs/trigger-generator.d.ts.map +1 -1
- package/dist/cjs/trigger-generator.js +32 -9
- package/dist/cjs/trigger-generator.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/workflow-component-plugin.d.ts +10 -1
- package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-component-plugin.js +79 -25
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/esm/trigger-generator.d.ts +2 -0
- package/dist/esm/trigger-generator.d.ts.map +1 -1
- package/dist/esm/trigger-generator.js +29 -8
- package/dist/esm/trigger-generator.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/workflow-component-plugin.d.ts +10 -1
- package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-component-plugin.js +76 -25
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/package.json +2 -2
- package/src/trigger-generator.ts +41 -10
- package/src/workflow-component-plugin.ts +112 -25
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// An interval bound to a component runs INSIDE that component: its timer lives
|
|
2
|
+
// in the component's own effect, where the component's state setters are, and
|
|
3
|
+
// stops when the component is torn down. A page-scoped interval can do neither
|
|
4
|
+
// for a component — its writes to component state are skipped — which is why
|
|
5
|
+
// "switch the photo every 5 seconds" inside a component never worked.
|
|
6
|
+
|
|
7
|
+
import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
|
|
8
|
+
|
|
9
|
+
const rotationWorkflow = (scope: 'component' | 'page', config: Record<string, unknown>) => ({
|
|
10
|
+
id: 'wf-rotation',
|
|
11
|
+
name: 'Photo Rotation',
|
|
12
|
+
trigger: {
|
|
13
|
+
type: 'event-interval',
|
|
14
|
+
nodeId: 'trigger-interval',
|
|
15
|
+
scope,
|
|
16
|
+
config: { intervalMs: 5000, autoStart: true, ...config },
|
|
17
|
+
},
|
|
18
|
+
nodes: [
|
|
19
|
+
{
|
|
20
|
+
id: 'advance',
|
|
21
|
+
type: 'state-update-local-state',
|
|
22
|
+
config: { property: 'currentIndex', value: 1 },
|
|
23
|
+
stepNumber: 1,
|
|
24
|
+
label: 'Advance',
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
edges: [{ id: 'e1', source: 'trigger-interval', target: 'advance' }],
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const jsxComponentChunk = () => ({
|
|
31
|
+
type: 'chunk-type-ast',
|
|
32
|
+
name: 'jsx-component',
|
|
33
|
+
content: {
|
|
34
|
+
type: 'VariableDeclaration',
|
|
35
|
+
declarations: [
|
|
36
|
+
{
|
|
37
|
+
type: 'VariableDeclarator',
|
|
38
|
+
init: {
|
|
39
|
+
type: 'ArrowFunctionExpression',
|
|
40
|
+
body: {
|
|
41
|
+
type: 'BlockStatement',
|
|
42
|
+
body: [{ type: 'ReturnStatement', argument: null }],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const getWorkflowModule = async (
|
|
51
|
+
uidl: Record<string, unknown>,
|
|
52
|
+
workflow: any,
|
|
53
|
+
isPage: boolean
|
|
54
|
+
): Promise<string | null> => {
|
|
55
|
+
const plugin = createNextWorkflowPlugin({ isPage })
|
|
56
|
+
const structure: any = {
|
|
57
|
+
uidl: {
|
|
58
|
+
node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
|
|
59
|
+
stateDefinitions: { currentIndex: { type: 'number', defaultValue: 0 } },
|
|
60
|
+
...uidl,
|
|
61
|
+
},
|
|
62
|
+
chunks: [jsxComponentChunk()],
|
|
63
|
+
options: { workflows: { workflows: { [workflow.id]: workflow }, customNodes: {} } },
|
|
64
|
+
dependencies: {},
|
|
65
|
+
}
|
|
66
|
+
await plugin(structure)
|
|
67
|
+
const moduleChunk = structure.chunks.find((c: any) => c.name === 'workflow-module')
|
|
68
|
+
return moduleChunk ? String(moduleChunk.content) : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('an interval bound to a component', () => {
|
|
72
|
+
const bound = { componentId: 'TQ_oneOfOne', componentName: 'One of One' }
|
|
73
|
+
|
|
74
|
+
it('is generated into that component, with its timer and its teardown', async () => {
|
|
75
|
+
const code = await getWorkflowModule(
|
|
76
|
+
{ name: 'OneOfOne' },
|
|
77
|
+
rotationWorkflow('component', bound),
|
|
78
|
+
false
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
expect(code).not.toBeNull()
|
|
82
|
+
expect(code).toContain('Interval 5000ms')
|
|
83
|
+
expect(code).toContain('__iv_start_wf_rotation();')
|
|
84
|
+
expect(code).toContain('__iv_stop_wf_rotation();')
|
|
85
|
+
// The component's own handler factory, the one given its state setters.
|
|
86
|
+
expect(code).toContain('function __createWorkflowHandlers(stateSetters')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('is generated into no page and no other component', async () => {
|
|
90
|
+
const intoPage = await getWorkflowModule(
|
|
91
|
+
{ name: 'Home', outputOptions: { pageId: 'page-1', fileName: 'home' } },
|
|
92
|
+
rotationWorkflow('component', bound),
|
|
93
|
+
true
|
|
94
|
+
)
|
|
95
|
+
const intoOther = await getWorkflowModule(
|
|
96
|
+
{ name: 'Footer' },
|
|
97
|
+
rotationWorkflow('component', bound),
|
|
98
|
+
false
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
expect(intoPage).toBeNull()
|
|
102
|
+
expect(intoOther).toBeNull()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('leaves a page interval where it was: in its page, never in a component', async () => {
|
|
106
|
+
const intoPage = await getWorkflowModule(
|
|
107
|
+
{ name: 'Home', outputOptions: { pageId: 'page-1', fileName: 'home' } },
|
|
108
|
+
rotationWorkflow('page', { pageId: 'page-1' }),
|
|
109
|
+
true
|
|
110
|
+
)
|
|
111
|
+
const intoComponent = await getWorkflowModule(
|
|
112
|
+
{ name: 'OneOfOne' },
|
|
113
|
+
rotationWorkflow('page', { pageId: 'page-1' }),
|
|
114
|
+
false
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
expect(intoPage).toContain('Interval 5000ms')
|
|
118
|
+
expect(intoComponent).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// What an element trigger hands its workflow, on both ways a handler reaches
|
|
2
|
+
// the element: the React prop placed on it in the JSX, and the native listener
|
|
3
|
+
// attached by id when the element is not in the component's own JSX.
|
|
4
|
+
//
|
|
5
|
+
// A click delivers the bound element's `data-*` attributes under the names the
|
|
6
|
+
// static HTML export resolves (`data-tab-index` is `dataTabIndex`): a tab strip
|
|
7
|
+
// repeats one authored button per tab, and the attribute is the only thing
|
|
8
|
+
// that tells the buttons apart.
|
|
9
|
+
|
|
10
|
+
import type { UIDLWorkflow } from '@teleporthq/teleport-types'
|
|
11
|
+
import {
|
|
12
|
+
generateElementHandler,
|
|
13
|
+
generateUnmatchedElementFallback,
|
|
14
|
+
} from '../src/workflow-component-plugin'
|
|
15
|
+
|
|
16
|
+
const ELEMENT_ID = 'tab-button'
|
|
17
|
+
const SAFE_ID = 'wf_tabs'
|
|
18
|
+
|
|
19
|
+
const triggerInfo = (type: string, config: Record<string, unknown> = {}) => ({
|
|
20
|
+
workflow: {
|
|
21
|
+
id: 'wf-tabs',
|
|
22
|
+
name: 'Switch tab',
|
|
23
|
+
trigger: { nodeId: 'trigger', type, scope: 'element', config },
|
|
24
|
+
nodes: [],
|
|
25
|
+
edges: [],
|
|
26
|
+
} as unknown as UIDLWorkflow,
|
|
27
|
+
elementId: ELEMENT_ID,
|
|
28
|
+
reactProp: 'onClick',
|
|
29
|
+
triggerConfig: config,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
type TriggerContext = Record<string, unknown>
|
|
33
|
+
type Listener = (event: FakeEvent) => void
|
|
34
|
+
|
|
35
|
+
interface FakeEvent {
|
|
36
|
+
type: string
|
|
37
|
+
target: FakeElement
|
|
38
|
+
currentTarget: FakeElement | null
|
|
39
|
+
clientX?: number
|
|
40
|
+
preventDefault: () => void
|
|
41
|
+
stopPropagation: () => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* The few things a handler touches on an element, behaving as a browser's do:
|
|
45
|
+
`dataset` holds the `data-*` attributes under camelCased keys, an event
|
|
46
|
+
bubbles to every ancestor with `currentTarget` set to the one being visited,
|
|
47
|
+
and `currentTarget` is null once the dispatch is over. */
|
|
48
|
+
class FakeElement {
|
|
49
|
+
public dataset: Record<string, string> = {}
|
|
50
|
+
public parent: FakeElement | null = null
|
|
51
|
+
public formFields: Record<string, string> = {}
|
|
52
|
+
public value?: string
|
|
53
|
+
private listeners: Record<string, Listener[]> = {}
|
|
54
|
+
|
|
55
|
+
constructor(public id: string, dataAttributes: Record<string, string> = {}) {
|
|
56
|
+
for (const name of Object.keys(dataAttributes)) {
|
|
57
|
+
const key = name
|
|
58
|
+
.replace(/^data-/, '')
|
|
59
|
+
.replace(/-([a-z])/g, (_match, letter: string) => letter.toUpperCase())
|
|
60
|
+
this.dataset[key] = dataAttributes[name]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public append(child: FakeElement): FakeElement {
|
|
65
|
+
child.parent = this
|
|
66
|
+
return this
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public addEventListener(type: string, listener: Listener): void {
|
|
70
|
+
this.listeners[type] = [...(this.listeners[type] || []), listener]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public removeEventListener(): void {
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
public selfAndAncestors(): FakeElement[] {
|
|
78
|
+
return [this as FakeElement].concat(this.parent ? this.parent.selfAndAncestors() : [])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public dispatch(type: string, init: { clientX?: number } = {}): void {
|
|
82
|
+
const event: FakeEvent = {
|
|
83
|
+
type,
|
|
84
|
+
target: this,
|
|
85
|
+
currentTarget: null,
|
|
86
|
+
preventDefault: () => undefined,
|
|
87
|
+
stopPropagation: () => undefined,
|
|
88
|
+
...init,
|
|
89
|
+
}
|
|
90
|
+
for (const visited of this.selfAndAncestors()) {
|
|
91
|
+
event.currentTarget = visited
|
|
92
|
+
;(visited.listeners[type] || []).forEach((listener) => listener(event))
|
|
93
|
+
}
|
|
94
|
+
event.currentTarget = null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
class FakeFormData {
|
|
99
|
+
constructor(private form: FakeElement) {}
|
|
100
|
+
public forEach(visit: (value: string, key: string) => void): void {
|
|
101
|
+
Object.keys(this.form.formFields).forEach((key) => visit(this.form.formFields[key], key))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const page = (bound: FakeElement, child: FakeElement) => {
|
|
106
|
+
bound.append(child)
|
|
107
|
+
const byId: Record<string, FakeElement> = { [bound.id]: bound, [child.id]: child }
|
|
108
|
+
const received: TriggerContext[] = []
|
|
109
|
+
const execWf = (_config: unknown, triggerContext: TriggerContext) => {
|
|
110
|
+
received.push(triggerContext)
|
|
111
|
+
return Promise.resolve({})
|
|
112
|
+
}
|
|
113
|
+
const document = { getElementById: (id: string) => byId[id] || null }
|
|
114
|
+
return { bound, child, document, received, execWf }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The React-prop handler, attached the way React attaches it: on the bound element. */
|
|
118
|
+
const attachJsxHandler = (
|
|
119
|
+
site: ReturnType<typeof page>,
|
|
120
|
+
type: string,
|
|
121
|
+
domEvent: string,
|
|
122
|
+
config: Record<string, unknown> = {}
|
|
123
|
+
) => {
|
|
124
|
+
const source = generateElementHandler(triggerInfo(type, config))
|
|
125
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
126
|
+
const handler = new Function(
|
|
127
|
+
'__execWf',
|
|
128
|
+
`__wfConfig_${SAFE_ID}`,
|
|
129
|
+
`__wfServerUrls_${SAFE_ID}`,
|
|
130
|
+
'FormData',
|
|
131
|
+
'window',
|
|
132
|
+
`return ${source};`
|
|
133
|
+
)(site.execWf, {}, {}, FakeFormData, {})
|
|
134
|
+
site.bound.addEventListener(domEvent, handler)
|
|
135
|
+
return source
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const attachFallback = (
|
|
139
|
+
site: ReturnType<typeof page>,
|
|
140
|
+
type: string,
|
|
141
|
+
_domEvent: string,
|
|
142
|
+
config: Record<string, unknown> = {}
|
|
143
|
+
) => {
|
|
144
|
+
const source = generateUnmatchedElementFallback(triggerInfo(type, config), SAFE_ID)
|
|
145
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
146
|
+
new Function(
|
|
147
|
+
'document',
|
|
148
|
+
'cleanups',
|
|
149
|
+
'__execWf',
|
|
150
|
+
`__wfConfig_${SAFE_ID}`,
|
|
151
|
+
`__wfServerUrls_${SAFE_ID}`,
|
|
152
|
+
'FormData',
|
|
153
|
+
source
|
|
154
|
+
)(site.document, [], site.execWf, {}, {}, FakeFormData)
|
|
155
|
+
return source
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const tabPage = (dataAttributes: Record<string, string>) =>
|
|
159
|
+
page(new FakeElement(ELEMENT_ID, dataAttributes), new FakeElement('label'))
|
|
160
|
+
|
|
161
|
+
const TAB_ATTRIBUTES = {
|
|
162
|
+
'data-tab-index': '2',
|
|
163
|
+
'data-panel-group-name': 'plans',
|
|
164
|
+
'data-element-id': 'shadow',
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const clickLabel = (site: ReturnType<typeof page>) => site.child.dispatch('click', { clientX: 7 })
|
|
168
|
+
|
|
169
|
+
describe('a click trigger delivers the bound element data attributes', () => {
|
|
170
|
+
const DATASET_MAPPING = `fields['data' + key.charAt(0).toUpperCase() + key.slice(1)]`
|
|
171
|
+
|
|
172
|
+
it.each([
|
|
173
|
+
['the React prop', attachJsxHandler],
|
|
174
|
+
['the native-listener fallback', attachFallback],
|
|
175
|
+
])('through %s, read off the bound element when a child is clicked', (_name, attach) => {
|
|
176
|
+
const site = tabPage(TAB_ATTRIBUTES)
|
|
177
|
+
const source = attach(site, 'event-element-clicked', 'click')
|
|
178
|
+
expect(source).toContain(DATASET_MAPPING)
|
|
179
|
+
|
|
180
|
+
clickLabel(site)
|
|
181
|
+
|
|
182
|
+
expect(site.received).toHaveLength(1)
|
|
183
|
+
const [context] = site.received
|
|
184
|
+
expect(context.dataTabIndex).toBe('2')
|
|
185
|
+
expect(context.dataPanelGroupName).toBe('plans')
|
|
186
|
+
expect(context.elementId).toBe(ELEMENT_ID)
|
|
187
|
+
expect(context.clientX).toBe(7)
|
|
188
|
+
expect(typeof context.timestamp).toBe('number')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it.each([
|
|
192
|
+
['the React prop', attachJsxHandler],
|
|
193
|
+
['the native-listener fallback', attachFallback],
|
|
194
|
+
])('through %s, an attribute never replaces a field of the trigger', (_name, attach) => {
|
|
195
|
+
// `data-element-id` is delivered as `dataElementId`; the trigger's own
|
|
196
|
+
// `elementId` is assigned after the attributes and stays what it was.
|
|
197
|
+
const site = tabPage(TAB_ATTRIBUTES)
|
|
198
|
+
attach(site, 'event-element-clicked', 'click')
|
|
199
|
+
|
|
200
|
+
clickLabel(site)
|
|
201
|
+
|
|
202
|
+
expect(site.received[0].dataElementId).toBe('shadow')
|
|
203
|
+
expect(site.received[0].elementId).toBe(ELEMENT_ID)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('a debounced click still reads the bound element, after the event is over', async () => {
|
|
207
|
+
const site = tabPage(TAB_ATTRIBUTES)
|
|
208
|
+
attachJsxHandler(site, 'event-element-clicked', 'click', { debounce: 5 })
|
|
209
|
+
|
|
210
|
+
clickLabel(site)
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
212
|
+
|
|
213
|
+
expect(site.received).toHaveLength(1)
|
|
214
|
+
expect(site.received[0].dataTabIndex).toBe('2')
|
|
215
|
+
expect(site.received[0].element).toBe(site.bound)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('an element with no data attribute delivers the six trigger fields alone', () => {
|
|
219
|
+
const site = tabPage({})
|
|
220
|
+
attachJsxHandler(site, 'event-element-clicked', 'click')
|
|
221
|
+
|
|
222
|
+
clickLabel(site)
|
|
223
|
+
|
|
224
|
+
expect(Object.keys(site.received[0]).sort()).toEqual(
|
|
225
|
+
['clientX', 'clientY', 'element', 'elementId', 'timestamp', 'triggerElement'].sort()
|
|
226
|
+
)
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe('the native-listener fallback delivers what the React prop delivers', () => {
|
|
231
|
+
it('a submitted form: every field at the top level, and under fields and formData', () => {
|
|
232
|
+
const formPage = () => {
|
|
233
|
+
const form = new FakeElement(ELEMENT_ID)
|
|
234
|
+
form.formFields = { email: 'a@b.co', plan: 'pro' }
|
|
235
|
+
return page(form, new FakeElement('email'))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const viaProp = formPage()
|
|
239
|
+
attachJsxHandler(viaProp, 'event-form-submitted', 'submit')
|
|
240
|
+
viaProp.bound.dispatch('submit')
|
|
241
|
+
|
|
242
|
+
const viaFallback = formPage()
|
|
243
|
+
const source = attachFallback(viaFallback, 'event-form-submitted', 'submit')
|
|
244
|
+
viaFallback.bound.dispatch('submit')
|
|
245
|
+
|
|
246
|
+
expect(source).toContain('Object.assign({}, formData, { fields: formData, formData: formData')
|
|
247
|
+
const [context] = viaFallback.received
|
|
248
|
+
expect(context.email).toBe('a@b.co')
|
|
249
|
+
expect(context.plan).toBe('pro')
|
|
250
|
+
expect(context.fields).toEqual({ email: 'a@b.co', plan: 'pro' })
|
|
251
|
+
expect(context.formData).toEqual({ email: 'a@b.co', plan: 'pro' })
|
|
252
|
+
expect(context.formId).toBe(ELEMENT_ID)
|
|
253
|
+
expect(Object.keys(context).sort()).toEqual(Object.keys(viaProp.received[0]).sort())
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('an element event: the control that fired as target, the bound element as currentTarget', () => {
|
|
257
|
+
const config = { eventType: 'change' }
|
|
258
|
+
const radioPage = () => {
|
|
259
|
+
const radio = new FakeElement('choice')
|
|
260
|
+
radio.value = 'b'
|
|
261
|
+
return page(new FakeElement(ELEMENT_ID), radio)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const viaProp = radioPage()
|
|
265
|
+
attachJsxHandler(viaProp, 'event-element-event', 'change', config)
|
|
266
|
+
viaProp.child.dispatch('change')
|
|
267
|
+
|
|
268
|
+
const viaFallback = radioPage()
|
|
269
|
+
const source = attachFallback(viaFallback, 'event-element-event', 'change', config)
|
|
270
|
+
viaFallback.child.dispatch('change')
|
|
271
|
+
|
|
272
|
+
expect(source).toContain(`target: event.target, currentTarget: __el_${SAFE_ID}`)
|
|
273
|
+
const [context] = viaFallback.received
|
|
274
|
+
expect(context.target).toBe(viaFallback.child)
|
|
275
|
+
expect(context.currentTarget).toBe(viaFallback.bound)
|
|
276
|
+
expect(context.value).toBe('b')
|
|
277
|
+
expect(Object.keys(context).sort()).toEqual(Object.keys(viaProp.received[0]).sort())
|
|
278
|
+
})
|
|
279
|
+
})
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// A workflow reads a global state through the state values its file hands the
|
|
2
|
+
// workflow runtime, and a global state is among them only when the file takes
|
|
3
|
+
// it from `useGlobalState()`. A file whose workflows only READ a global state
|
|
4
|
+
// used to get none of that, so the read was undefined: a timer in a component,
|
|
5
|
+
// gated on a global on/off switch that a page flips, never ticked.
|
|
6
|
+
|
|
7
|
+
import generate from '@babel/generator'
|
|
8
|
+
import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
|
|
9
|
+
import { buildStateGetHandlerSource } from '../src/nodes/state/_state-get'
|
|
10
|
+
|
|
11
|
+
const GLOBAL_STATES = {
|
|
12
|
+
TQ_switch: { id: 'TQ_switch', name: 'tickerTimerEnabled', type: 'boolean', defaultValue: true },
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const COMPONENT = { componentId: 'TQ_ticker', componentName: 'Ticker' }
|
|
16
|
+
|
|
17
|
+
const node = (id: string, type: string, config: Record<string, unknown>) => ({
|
|
18
|
+
id,
|
|
19
|
+
type,
|
|
20
|
+
config,
|
|
21
|
+
stepNumber: 1,
|
|
22
|
+
label: id,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const UPDATE_COUNT = node('bump', 'state-update-local-state', { property: 'count', value: 1 })
|
|
26
|
+
|
|
27
|
+
const timerWorkflow = (
|
|
28
|
+
nodes: Array<ReturnType<typeof node>>,
|
|
29
|
+
trigger: Record<string, unknown> = { scope: 'component', config: COMPONENT }
|
|
30
|
+
) => ({
|
|
31
|
+
id: 'wf-ticker',
|
|
32
|
+
name: 'Ticker Count Update',
|
|
33
|
+
trigger: {
|
|
34
|
+
type: 'event-interval',
|
|
35
|
+
nodeId: 'tick',
|
|
36
|
+
scope: trigger.scope,
|
|
37
|
+
config: { intervalMs: 1000, autoStart: true, ...(trigger.config as object) },
|
|
38
|
+
},
|
|
39
|
+
nodes,
|
|
40
|
+
edges: nodes.map((target, index) => ({
|
|
41
|
+
id: `e${index}`,
|
|
42
|
+
source: index === 0 ? 'tick' : nodes[index - 1].id,
|
|
43
|
+
target: target.id,
|
|
44
|
+
})),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const jsxComponentChunk = () => ({
|
|
48
|
+
type: 'chunk-type-ast',
|
|
49
|
+
name: 'jsx-component',
|
|
50
|
+
content: {
|
|
51
|
+
type: 'VariableDeclaration',
|
|
52
|
+
kind: 'const',
|
|
53
|
+
declarations: [
|
|
54
|
+
{
|
|
55
|
+
type: 'VariableDeclarator',
|
|
56
|
+
id: { type: 'Identifier', name: 'Ticker' },
|
|
57
|
+
init: {
|
|
58
|
+
type: 'ArrowFunctionExpression',
|
|
59
|
+
params: [] as unknown[],
|
|
60
|
+
body: {
|
|
61
|
+
type: 'BlockStatement',
|
|
62
|
+
body: [{ type: 'ReturnStatement', argument: null as unknown }],
|
|
63
|
+
directives: [] as unknown[],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
interface Generated {
|
|
72
|
+
component: string
|
|
73
|
+
module: string
|
|
74
|
+
dependencies: Record<string, { path?: string }>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const generateFile = async (params: {
|
|
78
|
+
workflow: any
|
|
79
|
+
isPage: boolean
|
|
80
|
+
globalStateDefinitions?: Record<string, unknown>
|
|
81
|
+
customNodes?: Record<string, unknown>
|
|
82
|
+
}): Promise<Generated> => {
|
|
83
|
+
const { workflow, isPage, globalStateDefinitions = GLOBAL_STATES, customNodes = {} } = params
|
|
84
|
+
const plugin = createNextWorkflowPlugin({ isPage })
|
|
85
|
+
const structure: any = {
|
|
86
|
+
uidl: {
|
|
87
|
+
name: isPage ? 'Home' : 'Ticker',
|
|
88
|
+
node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
|
|
89
|
+
stateDefinitions: { count: { type: 'number', defaultValue: 0 } },
|
|
90
|
+
...(isPage ? { outputOptions: { pageId: 'page-1', fileName: 'index' } } : {}),
|
|
91
|
+
},
|
|
92
|
+
chunks: [jsxComponentChunk()],
|
|
93
|
+
options: {
|
|
94
|
+
workflows: { workflows: { [workflow.id]: workflow }, customNodes },
|
|
95
|
+
globalStateDefinitions,
|
|
96
|
+
},
|
|
97
|
+
dependencies: {},
|
|
98
|
+
}
|
|
99
|
+
await plugin(structure)
|
|
100
|
+
const componentChunk = structure.chunks.find((c: any) => c.name === 'jsx-component')
|
|
101
|
+
const moduleChunk = structure.chunks.find((c: any) => c.name === 'workflow-module')
|
|
102
|
+
return {
|
|
103
|
+
component: generate(componentChunk.content).code,
|
|
104
|
+
module: moduleChunk ? String(moduleChunk.content) : '',
|
|
105
|
+
dependencies: structure.dependencies,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const HOOK =
|
|
110
|
+
/const \{\s*tickerTimerEnabled,\s*setTickerTimerEnabled,\s*refreshGlobalState\s*\} = useGlobalState\(\)/
|
|
111
|
+
const VALUE_IN_REF =
|
|
112
|
+
/__wfStateRef\.current = \{\s*count: count,\s*tickerTimerEnabled: tickerTimerEnabled\s*\}/
|
|
113
|
+
|
|
114
|
+
const expectGlobalStateWired = (file: Generated) => {
|
|
115
|
+
expect(file.dependencies.useGlobalState).toEqual({
|
|
116
|
+
type: 'local',
|
|
117
|
+
path: '@/global-state-context',
|
|
118
|
+
meta: { namedImport: true },
|
|
119
|
+
})
|
|
120
|
+
expect(file.component).toMatch(HOOK)
|
|
121
|
+
// Assigned on every render, after the hook: a change of the global value
|
|
122
|
+
// re-renders the file, and the next run reads the new value.
|
|
123
|
+
expect(file.component).toMatch(VALUE_IN_REF)
|
|
124
|
+
expect(file.component.indexOf('useGlobalState()')).toBeLessThan(
|
|
125
|
+
file.component.indexOf('__wfStateRef.current =')
|
|
126
|
+
)
|
|
127
|
+
// The runtime is told the state's type, and each run starts from the ref.
|
|
128
|
+
expect(file.component).toMatch(/tickerTimerEnabled: "boolean"/)
|
|
129
|
+
expect(file.module).toContain(
|
|
130
|
+
'triggerContext.__stateValues = Object.assign({}, stateValuesRef.current);'
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const expectNoGlobalStateWiring = (file: Generated) => {
|
|
135
|
+
expect(file.dependencies.useGlobalState).toBeUndefined()
|
|
136
|
+
expect(file.component).not.toContain('useGlobalState')
|
|
137
|
+
expect(file.component).not.toContain('tickerTimerEnabled')
|
|
138
|
+
expect(file.component).toMatch(/__wfStateRef\.current = \{\s*count: count\s*\}/)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const READ_SWITCH = node('read-switch', 'state-get-global-state', {
|
|
142
|
+
property: 'tickerTimerEnabled',
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('a file whose workflows only read a global state', () => {
|
|
146
|
+
it('a component gets the import, the hook, and the value in its state ref', async () => {
|
|
147
|
+
const file = await generateFile({
|
|
148
|
+
workflow: timerWorkflow([READ_SWITCH, UPDATE_COUNT]),
|
|
149
|
+
isPage: false,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
expectGlobalStateWired(file)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('a page gets the same', async () => {
|
|
156
|
+
const file = await generateFile({
|
|
157
|
+
workflow: timerWorkflow([READ_SWITCH, UPDATE_COUNT], {
|
|
158
|
+
scope: 'page',
|
|
159
|
+
config: { pageId: 'page-1' },
|
|
160
|
+
}),
|
|
161
|
+
isPage: true,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
expectGlobalStateWired(file)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('counts a read made inside a custom node the workflow calls', async () => {
|
|
168
|
+
const file = await generateFile({
|
|
169
|
+
workflow: timerWorkflow([
|
|
170
|
+
node('call', 'general-custom-node', { customNodeId: 'shared-check' }),
|
|
171
|
+
UPDATE_COUNT,
|
|
172
|
+
]),
|
|
173
|
+
isPage: false,
|
|
174
|
+
customNodes: { 'shared-check': { id: 'shared-check', nodes: [READ_SWITCH], edges: [] } },
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
expectGlobalStateWired(file)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it.each([
|
|
181
|
+
['a {{state.<name>}} token', { value: '{{state.tickerTimerEnabled}}' }],
|
|
182
|
+
[
|
|
183
|
+
'a context reference into the state values',
|
|
184
|
+
{
|
|
185
|
+
value: {
|
|
186
|
+
type: 'workflowContext',
|
|
187
|
+
nodeId: 'tick',
|
|
188
|
+
path: ['tick', '__stateValues', 'tickerTimerEnabled'],
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
[
|
|
193
|
+
'an expression over the state values',
|
|
194
|
+
{ expression: 'context.__stateValues.tickerTimerEnabled === true' },
|
|
195
|
+
],
|
|
196
|
+
])('counts a read written as %s', async (_form, config) => {
|
|
197
|
+
const file = await generateFile({
|
|
198
|
+
workflow: timerWorkflow([node('gate', 'general-if-statement', config), UPDATE_COUNT]),
|
|
199
|
+
isPage: false,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
expectGlobalStateWired(file)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('a file that uses no global state is generated as before', () => {
|
|
207
|
+
it('a workflow on local state only gets no hook, in a project that has global states', async () => {
|
|
208
|
+
const file = await generateFile({ workflow: timerWorkflow([UPDATE_COUNT]), isPage: false })
|
|
209
|
+
|
|
210
|
+
expectNoGlobalStateWiring(file)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it.each([
|
|
214
|
+
['a token naming a local state', { value: '{{state.count}}' }],
|
|
215
|
+
['an expression over the state values naming none', { expression: '__stateValues.count > 3' }],
|
|
216
|
+
[
|
|
217
|
+
'a name that merely contains a global state name',
|
|
218
|
+
{ expression: '__stateValues.tickerTimerEnabledLabel' },
|
|
219
|
+
],
|
|
220
|
+
])('%s gets no hook', async (_form, config) => {
|
|
221
|
+
const file = await generateFile({
|
|
222
|
+
workflow: timerWorkflow([node('gate', 'general-if-statement', config), UPDATE_COUNT]),
|
|
223
|
+
isPage: false,
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
expectNoGlobalStateWiring(file)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('a read node in a project that declares no global state adds nothing', async () => {
|
|
230
|
+
const file = await generateFile({
|
|
231
|
+
workflow: timerWorkflow([READ_SWITCH, UPDATE_COUNT]),
|
|
232
|
+
isPage: false,
|
|
233
|
+
globalStateDefinitions: {},
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
expect(file.dependencies.useGlobalState).toBeUndefined()
|
|
237
|
+
expect(file.component).not.toContain('useGlobalState')
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
describe('the read node itself', () => {
|
|
242
|
+
it('returns the value the state ref held when the run started', async () => {
|
|
243
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
244
|
+
const handler = new Function(`return ${buildStateGetHandlerSource('state_get_global_state')}`)()
|
|
245
|
+
const stateValuesRef = { current: { tickerTimerEnabled: true } }
|
|
246
|
+
const context = { __stateValues: { ...stateValuesRef.current } }
|
|
247
|
+
|
|
248
|
+
await expect(handler({ property: 'tickerTimerEnabled' }, context)).resolves.toEqual({
|
|
249
|
+
value: true,
|
|
250
|
+
key: 'tickerTimerEnabled',
|
|
251
|
+
})
|
|
252
|
+
await expect(handler({ property: 'tickerTimerEnabled' }, {})).resolves.toEqual({
|
|
253
|
+
value: undefined,
|
|
254
|
+
key: 'tickerTimerEnabled',
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
})
|