@teleporthq/teleport-plugin-next-workflows 0.45.9 → 0.45.13

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.
@@ -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,217 @@
1
+ // A timer with a control event is steered by the "Emit Custom Event" node,
2
+ // which dispatches `workflow:custom:<name>`. The timer used to listen for the
3
+ // bare name only, so an emitted control event reached nothing; and it could
4
+ // only toggle, while workflows emit the event with a "start" or "stop" command.
5
+
6
+ import type { UIDLWorkflow } from '@teleporthq/teleport-types'
7
+ import { generateLifecycleTrigger } from '../src/workflow-component-plugin'
8
+ import { generateTriggerCode } from '../src/trigger-generator'
9
+
10
+ const CONTROL_EVENT = 'rotation-control'
11
+ const EMITTED = `workflow:custom:${CONTROL_EVENT}`
12
+ const SAFE_ID = 'wf_rotation'
13
+
14
+ const intervalWorkflow = (config: Record<string, unknown> = {}): UIDLWorkflow =>
15
+ ({
16
+ id: 'wf-rotation',
17
+ name: 'Photo Rotation',
18
+ trigger: {
19
+ type: 'event-interval',
20
+ nodeId: 'trigger-interval',
21
+ scope: 'component',
22
+ config: {
23
+ intervalMs: 1000,
24
+ autoStart: true,
25
+ runWhileHidden: true,
26
+ controlEventName: CONTROL_EVENT,
27
+ componentId: 'TQ_card',
28
+ ...config,
29
+ },
30
+ },
31
+ nodes: [],
32
+ edges: [],
33
+ } as unknown as UIDLWorkflow)
34
+
35
+ type Listener = (event: { detail?: unknown }) => void
36
+
37
+ const fakeWindow = () => {
38
+ const listeners: Record<string, Listener[]> = {}
39
+ return {
40
+ listeners,
41
+ addEventListener: (type: string, listener: Listener) => {
42
+ listeners[type] = [...(listeners[type] || []), listener]
43
+ },
44
+ removeEventListener: (type: string, listener: Listener) => {
45
+ listeners[type] = (listeners[type] || []).filter((registered) => registered !== listener)
46
+ },
47
+ emit: (type: string, detail?: unknown) =>
48
+ (listeners[type] || []).forEach((listener) => listener({ detail })),
49
+ listenerCount: () =>
50
+ Object.keys(listeners).reduce((count, type) => count + listeners[type].length, 0),
51
+ }
52
+ }
53
+
54
+ interface RunningTimer {
55
+ source: string
56
+ window: ReturnType<typeof fakeWindow>
57
+ ticks: () => number
58
+ teardown: () => void
59
+ }
60
+
61
+ /** The timer as a component sets it up in its effect. */
62
+ const inComponent = (config: Record<string, unknown> = {}): RunningTimer => {
63
+ const window = fakeWindow()
64
+ const cleanups: Array<() => void> = []
65
+ let ticks = 0
66
+ const source = generateLifecycleTrigger(intervalWorkflow(config), SAFE_ID)
67
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
68
+ new Function(
69
+ 'window',
70
+ 'document',
71
+ 'cleanups',
72
+ '__execWf',
73
+ `__wfConfig_${SAFE_ID}`,
74
+ `__wfServerUrls_${SAFE_ID}`,
75
+ source
76
+ )(window, {}, cleanups, () => ticks++, {}, {})
77
+ return {
78
+ source,
79
+ window,
80
+ ticks: () => ticks,
81
+ teardown: () => cleanups.forEach((cleanup) => cleanup()),
82
+ }
83
+ }
84
+
85
+ /** The same timer in its native-listener form, whose statements return the teardown. */
86
+ const asNativeTrigger = (config: Record<string, unknown> = {}): RunningTimer => {
87
+ const window = fakeWindow()
88
+ let ticks = 0
89
+ const source = generateTriggerCode(intervalWorkflow(config), 'runWorkflow')
90
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
91
+ const teardown = new Function('window', 'document', 'runWorkflow', source)(
92
+ window,
93
+ {},
94
+ () => ticks++
95
+ )
96
+ return { source, window, ticks: () => ticks, teardown }
97
+ }
98
+
99
+ describe.each([
100
+ ['in a component', inComponent],
101
+ ['as a native trigger', asNativeTrigger],
102
+ ])('a timer with a control event, %s', (_name, start) => {
103
+ beforeEach(() => jest.useFakeTimers())
104
+ afterEach(() => jest.useRealTimers())
105
+
106
+ it('hears the event the emit node dispatches, and the bare name', () => {
107
+ const timer = start()
108
+
109
+ expect(Object.keys(timer.window.listeners).sort()).toEqual([CONTROL_EVENT, EMITTED].sort())
110
+ expect(timer.source).toContain(`window.addEventListener("${EMITTED}", `)
111
+ timer.teardown()
112
+ })
113
+
114
+ it('stops ticking on "stop" and resumes on "start"', () => {
115
+ const timer = start()
116
+ jest.advanceTimersByTime(3000)
117
+ expect(timer.ticks()).toBe(3)
118
+
119
+ timer.window.emit(EMITTED, 'stop')
120
+ jest.advanceTimersByTime(3000)
121
+ expect(timer.ticks()).toBe(3)
122
+
123
+ timer.window.emit(EMITTED, 'start')
124
+ jest.advanceTimersByTime(2000)
125
+ expect(timer.ticks()).toBe(5)
126
+ timer.teardown()
127
+ })
128
+
129
+ it('"start" while running, twice over, leaves one timer; "stop" while stopped stays stopped', () => {
130
+ const timer = start()
131
+ timer.window.emit(EMITTED, 'start')
132
+ timer.window.emit(EMITTED, 'start')
133
+ jest.advanceTimersByTime(3000)
134
+ expect(timer.ticks()).toBe(3)
135
+
136
+ timer.window.emit(EMITTED, 'stop')
137
+ timer.window.emit(EMITTED, 'stop')
138
+ jest.advanceTimersByTime(3000)
139
+ expect(timer.ticks()).toBe(3)
140
+ timer.teardown()
141
+ })
142
+
143
+ it.each([
144
+ ['action', { action: 'stop' }, { action: 'start' }],
145
+ ['command', { command: 'stop' }, { command: 'start' }],
146
+ ])('reads the command from an object detail: %s', (_key, stop, resume) => {
147
+ const timer = start({ autoStart: false })
148
+ jest.advanceTimersByTime(2000)
149
+ expect(timer.ticks()).toBe(0)
150
+
151
+ timer.window.emit(EMITTED, resume)
152
+ timer.window.emit(EMITTED, resume)
153
+ jest.advanceTimersByTime(2000)
154
+ expect(timer.ticks()).toBe(2)
155
+
156
+ timer.window.emit(EMITTED, stop)
157
+ jest.advanceTimersByTime(2000)
158
+ expect(timer.ticks()).toBe(2)
159
+ timer.teardown()
160
+ })
161
+
162
+ it.each([
163
+ ['no detail', undefined],
164
+ ['the emit node default, an empty object', {}],
165
+ ['a detail that names no command', { photo: 3 }],
166
+ ['an unknown word', 'pause'],
167
+ ])('toggles on an event with %s', (_case, detail) => {
168
+ const timer = start()
169
+
170
+ timer.window.emit(EMITTED, detail)
171
+ jest.advanceTimersByTime(2000)
172
+ expect(timer.ticks()).toBe(0)
173
+
174
+ timer.window.emit(EMITTED, detail)
175
+ jest.advanceTimersByTime(2000)
176
+ expect(timer.ticks()).toBe(2)
177
+ timer.teardown()
178
+ })
179
+
180
+ it('still answers the bare event name, and one dispatch is handled once', () => {
181
+ const timer = start()
182
+
183
+ // Handled twice, a toggle would leave the timer running.
184
+ timer.window.emit(CONTROL_EVENT)
185
+ jest.advanceTimersByTime(2000)
186
+ expect(timer.ticks()).toBe(0)
187
+
188
+ timer.window.emit(CONTROL_EVENT, 'start')
189
+ jest.advanceTimersByTime(1000)
190
+ expect(timer.ticks()).toBe(1)
191
+ timer.teardown()
192
+ })
193
+
194
+ it('teardown removes both listeners and stops the timer for good', () => {
195
+ const timer = start()
196
+ const [emitted] = timer.window.listeners[EMITTED]
197
+ expect(timer.window.listenerCount()).toBe(2)
198
+
199
+ timer.teardown()
200
+
201
+ expect(timer.window.listenerCount()).toBe(0)
202
+ jest.advanceTimersByTime(3000)
203
+ expect(timer.ticks()).toBe(0)
204
+ // A late event, delivered to the handler it once had, starts nothing.
205
+ emitted({ detail: 'start' })
206
+ jest.advanceTimersByTime(3000)
207
+ expect(timer.ticks()).toBe(0)
208
+ })
209
+
210
+ it('a timer without a control event listens for nothing', () => {
211
+ const timer = start({ controlEventName: undefined })
212
+
213
+ expect(timer.window.listenerCount()).toBe(0)
214
+ expect(timer.source).not.toContain('__iv_ctrlHandler_')
215
+ timer.teardown()
216
+ })
217
+ })
@@ -20,6 +20,8 @@ import { UIDLWorkflow } from '@teleporthq/teleport-types';
20
20
  * `addEventListener` fallbacks).
21
21
  */
22
22
  export declare const formControlPropertyReads: (elementRef: string) => string;
23
+ export declare const intervalControlListenerSource: (safeId: string, controlEventName: string) => string;
24
+ export declare const intervalControlTeardownSource: (safeId: string, controlEventName: string) => string;
23
25
  export declare const generateTriggerCode: (workflow: UIDLWorkflow, workflowVarName: string) => string;
24
26
  export declare const TRACKER_IDS_EXPRESSION = "(typeof window !== 'undefined' && window.tpAnalytics && typeof window.tpAnalytics.ids === 'function' ? window.tpAnalytics.ids() : null)";
25
27
  export declare const TRACK_LEAD_SOURCE: (formId: string) => string;
@@ -1 +1 @@
1
- {"version":3,"file":"trigger-generator.d.ts","sourceRoot":"","sources":["../../src/trigger-generator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAA;AAGzD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,wBAAwB,eAAgB,MAAM,KAAG,MAC4B,CAAA;AAE1F,eAAO,MAAM,mBAAmB,aAAc,YAAY,mBAAmB,MAAM,KAAG,MA0DrF,CAAA;AAQD,eAAO,MAAM,sBAAsB,4IACwG,CAAA;AAI3I,eAAO,MAAM,iBAAiB,WAAY,MAAM,KAAG,MAC4H,CAAA"}
1
+ {"version":3,"file":"trigger-generator.d.ts","sourceRoot":"","sources":["../../src/trigger-generator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAA;AAGzD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,wBAAwB,eAAgB,MAAM,KAAG,MAC4B,CAAA;AAiB1F,eAAO,MAAM,6BAA6B,WAAY,MAAM,oBAAoB,MAAM,KAAG,MAc5E,CAAA;AAEb,eAAO,MAAM,6BAA6B,WAAY,MAAM,oBAAoB,MAAM,KAAG,MAM5E,CAAA;AAEb,eAAO,MAAM,mBAAmB,aAAc,YAAY,mBAAmB,MAAM,KAAG,MA0DrF,CAAA;AAQD,eAAO,MAAM,sBAAsB,4IACwG,CAAA;AAI3I,eAAO,MAAM,iBAAiB,WAAY,MAAM,KAAG,MAC4H,CAAA"}