@teleporthq/teleport-plugin-next-workflows 0.43.70 → 0.43.74
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__/chapter-reached-trigger.test.ts +141 -0
- package/__tests__/element-video-control.test.ts +102 -0
- package/__tests__/element-visible-settings.test.ts +128 -0
- package/__tests__/element-visible-trigger-scoping.test.ts +22 -0
- package/__tests__/scrolled-past-trigger.test.ts +189 -0
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +1 -0
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/element-visible-trigger.d.ts +32 -0
- package/dist/cjs/element-visible-trigger.d.ts.map +1 -0
- package/dist/cjs/element-visible-trigger.js +42 -0
- package/dist/cjs/element-visible-trigger.js.map +1 -0
- package/dist/cjs/index.d.ts +1 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/nodes/ai/ai-provider-utils-sources.generated.d.ts +13 -0
- package/dist/cjs/nodes/ai/ai-provider-utils-sources.generated.d.ts.map +1 -0
- package/dist/cjs/nodes/ai/ai-provider-utils-sources.generated.js +20 -0
- package/dist/cjs/nodes/ai/ai-provider-utils-sources.generated.js.map +1 -0
- package/dist/cjs/nodes/ai/ai-provider-utils.d.ts.map +1 -1
- package/dist/cjs/nodes/ai/ai-provider-utils.js +19 -27
- package/dist/cjs/nodes/ai/ai-provider-utils.js.map +1 -1
- package/dist/cjs/nodes/ai/ai-sql-select-guard-sources.generated.d.ts +7 -0
- package/dist/cjs/nodes/ai/ai-sql-select-guard-sources.generated.d.ts.map +1 -0
- package/dist/cjs/nodes/ai/ai-sql-select-guard-sources.generated.js +14 -0
- package/dist/cjs/nodes/ai/ai-sql-select-guard-sources.generated.js.map +1 -0
- package/dist/cjs/nodes/ai/ai-sql-select-guard.d.ts +0 -16
- package/dist/cjs/nodes/ai/ai-sql-select-guard.d.ts.map +1 -1
- package/dist/cjs/nodes/ai/ai-sql-select-guard.js +12 -16
- package/dist/cjs/nodes/ai/ai-sql-select-guard.js.map +1 -1
- package/dist/cjs/nodes/element/element-video-control.d.ts +3 -0
- package/dist/cjs/nodes/element/element-video-control.d.ts.map +1 -0
- package/dist/cjs/nodes/element/element-video-control.js +94 -0
- package/dist/cjs/nodes/element/element-video-control.js.map +1 -0
- package/dist/cjs/nodes/index.d.ts.map +1 -1
- package/dist/cjs/nodes/index.js +2 -1
- package/dist/cjs/nodes/index.js.map +1 -1
- package/dist/cjs/scroll-points.d.ts +14 -0
- package/dist/cjs/scroll-points.d.ts.map +1 -0
- package/dist/cjs/scroll-points.js +23 -0
- package/dist/cjs/scroll-points.js.map +1 -0
- package/dist/cjs/trigger-generator.d.ts.map +1 -1
- package/dist/cjs/trigger-generator.js +3 -15
- package/dist/cjs/trigger-generator.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 +121 -13
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +1 -0
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/element-visible-trigger.d.ts +32 -0
- package/dist/esm/element-visible-trigger.d.ts.map +1 -0
- package/dist/esm/element-visible-trigger.js +37 -0
- package/dist/esm/element-visible-trigger.js.map +1 -0
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/nodes/ai/ai-provider-utils-sources.generated.d.ts +13 -0
- package/dist/esm/nodes/ai/ai-provider-utils-sources.generated.d.ts.map +1 -0
- package/dist/esm/nodes/ai/ai-provider-utils-sources.generated.js +17 -0
- package/dist/esm/nodes/ai/ai-provider-utils-sources.generated.js.map +1 -0
- package/dist/esm/nodes/ai/ai-provider-utils.d.ts.map +1 -1
- package/dist/esm/nodes/ai/ai-provider-utils.js +19 -27
- package/dist/esm/nodes/ai/ai-provider-utils.js.map +1 -1
- package/dist/esm/nodes/ai/ai-sql-select-guard-sources.generated.d.ts +7 -0
- package/dist/esm/nodes/ai/ai-sql-select-guard-sources.generated.d.ts.map +1 -0
- package/dist/esm/nodes/ai/ai-sql-select-guard-sources.generated.js +11 -0
- package/dist/esm/nodes/ai/ai-sql-select-guard-sources.generated.js.map +1 -0
- package/dist/esm/nodes/ai/ai-sql-select-guard.d.ts +0 -16
- package/dist/esm/nodes/ai/ai-sql-select-guard.d.ts.map +1 -1
- package/dist/esm/nodes/ai/ai-sql-select-guard.js +10 -14
- package/dist/esm/nodes/ai/ai-sql-select-guard.js.map +1 -1
- package/dist/esm/nodes/element/element-video-control.d.ts +3 -0
- package/dist/esm/nodes/element/element-video-control.d.ts.map +1 -0
- package/dist/esm/nodes/element/element-video-control.js +91 -0
- package/dist/esm/nodes/element/element-video-control.js.map +1 -0
- package/dist/esm/nodes/index.d.ts.map +1 -1
- package/dist/esm/nodes/index.js +2 -1
- package/dist/esm/nodes/index.js.map +1 -1
- package/dist/esm/scroll-points.d.ts +14 -0
- package/dist/esm/scroll-points.d.ts.map +1 -0
- package/dist/esm/scroll-points.js +19 -0
- package/dist/esm/scroll-points.js.map +1 -0
- package/dist/esm/trigger-generator.d.ts.map +1 -1
- package/dist/esm/trigger-generator.js +3 -15
- package/dist/esm/trigger-generator.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 +121 -13
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/package.json +4 -4
- package/src/api-route-generator.ts +1 -0
- package/src/element-visible-trigger.ts +63 -0
- package/src/index.ts +1 -0
- package/src/nodes/ai/ai-provider-utils-sources.generated.ts +26 -0
- package/src/nodes/ai/ai-provider-utils.ts +20 -29
- package/src/nodes/ai/ai-sql-select-guard-sources.generated.ts +14 -0
- package/src/nodes/ai/ai-sql-select-guard.ts +20 -16
- package/src/nodes/element/element-video-control.ts +95 -0
- package/src/nodes/index.ts +2 -0
- package/src/scroll-points.ts +21 -0
- package/src/trigger-generator.ts +7 -16
- package/src/workflow-component-plugin.ts +135 -13
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// `event-chapter-reached` is a LIFECYCLE trigger bound to ONE element (a
|
|
2
|
+
// scroll-scene chapter): it listens for the `tq-chapter-reached` event the
|
|
3
|
+
// published TqScrollScene dispatches on the chapter, so it follows the same
|
|
4
|
+
// rules as element-visible — DOM id lookup, and pruned from every page whose
|
|
5
|
+
// JSX does not render the chapter.
|
|
6
|
+
|
|
7
|
+
import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
|
|
8
|
+
|
|
9
|
+
const HTML_ID = 'thq_chapter_pricing'
|
|
10
|
+
const NODE_ID = 'TQ__chapterPricing'
|
|
11
|
+
|
|
12
|
+
const chapterWorkflow = (config: Record<string, unknown>) => ({
|
|
13
|
+
id: 'wf-chapter-pricing',
|
|
14
|
+
name: 'Load pricing on chapter',
|
|
15
|
+
trigger: {
|
|
16
|
+
type: 'event-chapter-reached',
|
|
17
|
+
nodeId: 'trigger-chapter',
|
|
18
|
+
scope: 'element',
|
|
19
|
+
config,
|
|
20
|
+
},
|
|
21
|
+
nodes: [
|
|
22
|
+
{
|
|
23
|
+
id: 'update-1',
|
|
24
|
+
type: 'state-update-local-state',
|
|
25
|
+
config: { property: 'pricingLoaded', value: true },
|
|
26
|
+
stepNumber: 1,
|
|
27
|
+
label: 'Mark loaded',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
edges: [{ id: 'e1', source: 'trigger-chapter', target: 'update-1' }],
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const jsxWithElementId = (elementId: string | null) => ({
|
|
34
|
+
type: 'chunk-type-ast',
|
|
35
|
+
name: 'jsx-component',
|
|
36
|
+
content: {
|
|
37
|
+
type: 'VariableDeclaration',
|
|
38
|
+
declarations: [
|
|
39
|
+
{
|
|
40
|
+
type: 'VariableDeclarator',
|
|
41
|
+
init: {
|
|
42
|
+
type: 'ArrowFunctionExpression',
|
|
43
|
+
body: {
|
|
44
|
+
type: 'BlockStatement',
|
|
45
|
+
body: [
|
|
46
|
+
{
|
|
47
|
+
type: 'ReturnStatement',
|
|
48
|
+
argument: {
|
|
49
|
+
type: 'JSXElement',
|
|
50
|
+
openingElement: {
|
|
51
|
+
type: 'JSXOpeningElement',
|
|
52
|
+
name: { type: 'JSXIdentifier', name: 'div' },
|
|
53
|
+
attributes: elementId
|
|
54
|
+
? [
|
|
55
|
+
{
|
|
56
|
+
type: 'JSXAttribute',
|
|
57
|
+
name: { type: 'JSXIdentifier', name: 'id' },
|
|
58
|
+
value: { type: 'StringLiteral', value: elementId },
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
: [],
|
|
62
|
+
},
|
|
63
|
+
children: [],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const getWorkflowModule = async (
|
|
75
|
+
workflow: any,
|
|
76
|
+
renderedElementId: string | null
|
|
77
|
+
): Promise<string | null> => {
|
|
78
|
+
const plugin = createNextWorkflowPlugin({ isPage: true })
|
|
79
|
+
const structure: any = {
|
|
80
|
+
uidl: {
|
|
81
|
+
name: 'Story',
|
|
82
|
+
node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
|
|
83
|
+
stateDefinitions: { pricingLoaded: { type: 'boolean', defaultValue: false } },
|
|
84
|
+
},
|
|
85
|
+
chunks: [jsxWithElementId(renderedElementId)],
|
|
86
|
+
options: { workflows: { workflows: { [workflow.id]: workflow }, customNodes: {} } },
|
|
87
|
+
dependencies: {},
|
|
88
|
+
}
|
|
89
|
+
await plugin(structure)
|
|
90
|
+
const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module')
|
|
91
|
+
return moduleChunk ? String(moduleChunk.content) : null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('event-chapter-reached — generated listener', () => {
|
|
95
|
+
const module = (config: Record<string, unknown>, rendered: string | null = HTML_ID) =>
|
|
96
|
+
getWorkflowModule(chapterWorkflow(config), rendered)
|
|
97
|
+
|
|
98
|
+
it('listens on the chapter DOM id for the scene announcement', async () => {
|
|
99
|
+
const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID })
|
|
100
|
+
expect(code).not.toBeNull()
|
|
101
|
+
expect(code).toContain(`document.getElementById('${HTML_ID}')`)
|
|
102
|
+
expect(code).toContain("addEventListener('tq-chapter-reached'")
|
|
103
|
+
expect(code).toContain(`elementId: '${HTML_ID}'`)
|
|
104
|
+
expect(code).toContain('chapterIndex: detail.chapterIndex')
|
|
105
|
+
expect(code).not.toContain(NODE_ID)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('listens on the chapter root and ignores announcements bubbling from a nested scene', async () => {
|
|
109
|
+
const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID })
|
|
110
|
+
expect(code).toContain(".closest('[data-scene-stage] > *, [data-scene-track] > *')")
|
|
111
|
+
expect(code).toMatch(/if \(event\.target !== __chRoot_\w+\) \{ return; \}/)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('fires once by default and detaches after the first announcement', async () => {
|
|
115
|
+
const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID })
|
|
116
|
+
expect(code).toMatch(
|
|
117
|
+
/__chRoot_\w+\.removeEventListener\('tq-chapter-reached', __chH_\w+\);\n\s+__chFire_/
|
|
118
|
+
)
|
|
119
|
+
expect(code).toMatch(/if \(!__chStamp_\w+\) \{/)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('keeps listening when the author asked for every time', async () => {
|
|
123
|
+
const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID, once: false })
|
|
124
|
+
expect(code).not.toMatch(
|
|
125
|
+
/__chRoot_\w+\.removeEventListener\('tq-chapter-reached', __chH_\w+\);\n\s+__chFire_/
|
|
126
|
+
)
|
|
127
|
+
expect(code).not.toMatch(/if \(!__chStamp_\w+\) \{/)
|
|
128
|
+
expect(code).toContain("addEventListener('tq-chapter-reached'")
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('reads the stamped chapter and count when it attaches after the announcement', async () => {
|
|
132
|
+
const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID })
|
|
133
|
+
expect(code).toContain("getAttribute('data-chapter-active')")
|
|
134
|
+
expect(code).toContain("getAttribute('data-chapter-count')")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('is pruned from a page that does not render the chapter', async () => {
|
|
138
|
+
const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID }, 'some_other_element')
|
|
139
|
+
expect(code === null || !code.includes('tq-chapter-reached')).toBe(true)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// "Control a Video" runs in the browser against the bound Video element. The
|
|
2
|
+
// generated handler is exercised here on a stubbed DOM: every action, the
|
|
3
|
+
// container-holding-a-video case, and the muted retry when the browser
|
|
4
|
+
// blocks a play with sound.
|
|
5
|
+
|
|
6
|
+
import { nodeRegistry } from '../src'
|
|
7
|
+
import { resolveHandlerEntryName } from '../src/nodes/types'
|
|
8
|
+
|
|
9
|
+
type Handler = (config: Record<string, unknown>, context: Record<string, unknown>) => Promise<any>
|
|
10
|
+
|
|
11
|
+
const loadHandler = (): Handler => {
|
|
12
|
+
const source = nodeRegistry['element-video-control'].generateHandler()
|
|
13
|
+
const entry = resolveHandlerEntryName(source, 'element-video-control')
|
|
14
|
+
// eslint-disable-next-line no-new-func
|
|
15
|
+
return new Function(`${source}\nreturn ${entry};`)() as Handler
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const fakeVideo = (options: { blockSound?: boolean } = {}) => {
|
|
19
|
+
const video: any = {
|
|
20
|
+
tagName: 'VIDEO',
|
|
21
|
+
paused: true,
|
|
22
|
+
ended: false,
|
|
23
|
+
muted: false,
|
|
24
|
+
currentTime: 12,
|
|
25
|
+
playCalls: 0,
|
|
26
|
+
play() {
|
|
27
|
+
this.playCalls += 1
|
|
28
|
+
if (options.blockSound && !this.muted) {
|
|
29
|
+
return Promise.reject(new Error('NotAllowedError'))
|
|
30
|
+
}
|
|
31
|
+
this.paused = false
|
|
32
|
+
return Promise.resolve()
|
|
33
|
+
},
|
|
34
|
+
pause() {
|
|
35
|
+
this.paused = true
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
return video
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const withDom = (byId: Record<string, any>) => {
|
|
42
|
+
;(globalThis as any).document = {
|
|
43
|
+
getElementById: (id: string) => byId[id] || null,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('element-video-control — generated handler', () => {
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
delete (globalThis as any).document
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('registers as a client-side element node', () => {
|
|
53
|
+
expect(nodeRegistry['element-video-control'].executionEnv).toBe('client')
|
|
54
|
+
expect(nodeRegistry['element-video-control'].generateHandler()).not.toContain('`')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('plays by default and reports the state', async () => {
|
|
58
|
+
const video = fakeVideo()
|
|
59
|
+
withDom({ hero: video })
|
|
60
|
+
const result = await loadHandler()({ nodeId: 'hero' }, {})
|
|
61
|
+
expect(result).toMatchObject({ success: true, action: 'play', playing: true, currentTime: 12 })
|
|
62
|
+
expect(video.playCalls).toBe(1)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('finds the video inside a bound container and resolves the html id fallback', async () => {
|
|
66
|
+
const video = fakeVideo()
|
|
67
|
+
const container = { tagName: 'DIV', querySelector: () => video }
|
|
68
|
+
withDom({ thq_hero: container })
|
|
69
|
+
const result = await loadHandler()(
|
|
70
|
+
{ nodeId: 'TQ_hero', elementHtmlId: 'thq_hero', action: 'pause' },
|
|
71
|
+
{}
|
|
72
|
+
)
|
|
73
|
+
expect(result).toMatchObject({ success: true, action: 'pause', playing: false })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('pauses, toggles, restarts, mutes and unmutes', async () => {
|
|
77
|
+
const video = fakeVideo()
|
|
78
|
+
video.paused = false
|
|
79
|
+
withDom({ v: video })
|
|
80
|
+
const handler = loadHandler()
|
|
81
|
+
expect((await handler({ nodeId: 'v', action: 'toggle' }, {})).playing).toBe(false)
|
|
82
|
+
expect((await handler({ nodeId: 'v', action: 'toggle' }, {})).playing).toBe(true)
|
|
83
|
+
const restarted = await handler({ nodeId: 'v', action: 'restart' }, {})
|
|
84
|
+
expect(restarted).toMatchObject({ playing: true, currentTime: 0 })
|
|
85
|
+
expect((await handler({ nodeId: 'v', action: 'mute' }, {})).muted).toBe(true)
|
|
86
|
+
expect((await handler({ nodeId: 'v', action: 'unmute' }, {})).muted).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('retries muted when the browser blocks a play with sound, and says so', async () => {
|
|
90
|
+
const video = fakeVideo({ blockSound: true })
|
|
91
|
+
withDom({ v: video })
|
|
92
|
+
const result = await loadHandler()({ nodeId: 'v', action: 'play' }, {})
|
|
93
|
+
expect(result).toMatchObject({ success: true, playing: true, muted: true, mutedFallback: true })
|
|
94
|
+
expect(video.playCalls).toBe(2)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('reports a missing video instead of throwing', async () => {
|
|
98
|
+
withDom({})
|
|
99
|
+
const result = await loadHandler()({ nodeId: 'nowhere', action: 'play' }, {})
|
|
100
|
+
expect(result).toEqual({ success: false, action: 'play', reason: 'video-not-found' })
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The "On Visible" trigger's settings, as the editor's inspector writes them:
|
|
2
|
+
// a PERCENT threshold and `once` (formerly `triggerOnce`). Before 2026-09-18 the
|
|
3
|
+
// percent reached IntersectionObserver unconverted — Chrome 153: "RangeError:
|
|
4
|
+
// Failed to construct 'IntersectionObserver': Threshold values must be numbers
|
|
5
|
+
// between 0 and 1" — and "run once" was never read.
|
|
6
|
+
|
|
7
|
+
import { elementVisibleObserverCode, elementVisibleSettings } from '../src/element-visible-trigger'
|
|
8
|
+
import { generateTriggerCode } from '../src/trigger-generator'
|
|
9
|
+
|
|
10
|
+
describe('elementVisibleSettings', () => {
|
|
11
|
+
it('turns the inspector percent into the share the browser counts, within 0–1', () => {
|
|
12
|
+
expect(elementVisibleSettings({ threshold: 50 }).threshold).toBe(0.5)
|
|
13
|
+
expect(elementVisibleSettings({ threshold: 100 }).threshold).toBe(1)
|
|
14
|
+
expect(elementVisibleSettings({ threshold: 250 }).threshold).toBe(1)
|
|
15
|
+
expect(elementVisibleSettings({ threshold: -5 }).threshold).toBe(0)
|
|
16
|
+
expect(elementVisibleSettings({}).threshold).toBe(0)
|
|
17
|
+
expect(elementVisibleSettings({ threshold: 'x' }).threshold).toBe(0)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('runs once unless told otherwise, and still reads the name older projects saved', () => {
|
|
21
|
+
expect(elementVisibleSettings({}).once).toBe(true)
|
|
22
|
+
expect(elementVisibleSettings({ once: false }).once).toBe(false)
|
|
23
|
+
expect(elementVisibleSettings({ triggerOnce: false }).once).toBe(false)
|
|
24
|
+
expect(elementVisibleSettings({ triggerOnce: true }).once).toBe(true)
|
|
25
|
+
// the current name wins when both are present
|
|
26
|
+
expect(elementVisibleSettings({ once: true, triggerOnce: false }).once).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Runs the generated statements against a fake IntersectionObserver and lets a
|
|
32
|
+
* test feed it the entries a browser would, one visibility change at a time.
|
|
33
|
+
*/
|
|
34
|
+
const observe = (config: Record<string, unknown>, viewportHeight = 1000) => {
|
|
35
|
+
let callback: (entries: unknown[]) => void = () => undefined
|
|
36
|
+
let options: { threshold: number | number[] } = { threshold: 0 }
|
|
37
|
+
let disconnected = false
|
|
38
|
+
class FakeObserver {
|
|
39
|
+
constructor(cb: (entries: unknown[]) => void, opts: { threshold: number | number[] }) {
|
|
40
|
+
callback = cb
|
|
41
|
+
options = opts
|
|
42
|
+
}
|
|
43
|
+
disconnect() {
|
|
44
|
+
disconnected = true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const runs: number[] = []
|
|
48
|
+
const code = elementVisibleObserverCode('obs', config, 'runs.push(entry.intersectionRatio);')
|
|
49
|
+
// tslint:disable-next-line:function-constructor
|
|
50
|
+
new Function('IntersectionObserver', 'window', 'runs', code)(
|
|
51
|
+
FakeObserver,
|
|
52
|
+
{ innerHeight: viewportHeight },
|
|
53
|
+
runs
|
|
54
|
+
)
|
|
55
|
+
const see = (ratio: number, height = 400) => {
|
|
56
|
+
if (!disconnected) {
|
|
57
|
+
callback([
|
|
58
|
+
{ isIntersecting: ratio > 0, intersectionRatio: ratio, boundingClientRect: { height } },
|
|
59
|
+
])
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { runs, see, options: () => options, disconnected: () => disconnected }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('the generated observer', () => {
|
|
66
|
+
it('builds a valid observer for a threshold typed in the inspector', () => {
|
|
67
|
+
const { options } = observe({ threshold: 50 })
|
|
68
|
+
const thresholds = ([] as number[]).concat(options().threshold)
|
|
69
|
+
expect(thresholds.every((value) => value >= 0 && value <= 1)).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('runs when enough of the element is on screen, once by default', () => {
|
|
73
|
+
const trigger = observe({ threshold: 50 })
|
|
74
|
+
trigger.see(0.3)
|
|
75
|
+
expect(trigger.runs).toEqual([])
|
|
76
|
+
trigger.see(0.55)
|
|
77
|
+
expect(trigger.runs).toEqual([0.55])
|
|
78
|
+
expect(trigger.disconnected()).toBe(true)
|
|
79
|
+
trigger.see(0.1)
|
|
80
|
+
trigger.see(0.9)
|
|
81
|
+
expect(trigger.runs).toEqual([0.55])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('"every time" runs again after the element has left, never twice while it stays', () => {
|
|
85
|
+
const trigger = observe({ threshold: 50, triggerOnce: false })
|
|
86
|
+
trigger.see(0.6)
|
|
87
|
+
trigger.see(0.8)
|
|
88
|
+
trigger.see(0.65)
|
|
89
|
+
expect(trigger.runs).toEqual([0.6])
|
|
90
|
+
trigger.see(0.2)
|
|
91
|
+
trigger.see(0.7)
|
|
92
|
+
expect(trigger.runs).toEqual([0.6, 0.7])
|
|
93
|
+
expect(trigger.disconnected()).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('an element taller than the screen runs at what it can reach', () => {
|
|
97
|
+
// 3000px tall in a 1000px screen: at most a third of it is ever visible
|
|
98
|
+
const trigger = observe({ threshold: 60 }, 1000)
|
|
99
|
+
trigger.see(0.32, 3000)
|
|
100
|
+
expect(trigger.runs).toEqual([0.32])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('with no threshold it runs as soon as any part appears', () => {
|
|
104
|
+
const trigger = observe({})
|
|
105
|
+
expect(trigger.options().threshold).toBe(0)
|
|
106
|
+
trigger.see(0.01)
|
|
107
|
+
expect(trigger.runs).toEqual([0.01])
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('both generator paths use it', () => {
|
|
112
|
+
it('the exported trigger generator no longer passes the percent to the browser', () => {
|
|
113
|
+
const code = generateTriggerCode(
|
|
114
|
+
{
|
|
115
|
+
id: 'wf-1',
|
|
116
|
+
trigger: {
|
|
117
|
+
type: 'event-element-visible',
|
|
118
|
+
config: { elementHtmlId: 'hero', threshold: 50, triggerOnce: false },
|
|
119
|
+
},
|
|
120
|
+
} as never,
|
|
121
|
+
'wf'
|
|
122
|
+
)
|
|
123
|
+
expect(code).not.toContain('threshold: 50')
|
|
124
|
+
expect(code).toContain('Math.min(0.5, reach * 0.9)')
|
|
125
|
+
// "every time": the only disconnect left is the cleanup's
|
|
126
|
+
expect(code.match(/disconnect\(\)/g)).toHaveLength(1)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -294,3 +294,25 @@ describe('event-element-visible — config/handler integrity when the module is
|
|
|
294
294
|
expect(code).toContain('const __wfConfig_wf_cookie_visible =')
|
|
295
295
|
})
|
|
296
296
|
})
|
|
297
|
+
|
|
298
|
+
// The inspector writes the threshold as a percent and, in projects saved before
|
|
299
|
+
// 2026-09-18, the run choice as `triggerOnce` (see element-visible-trigger.ts).
|
|
300
|
+
describe('event-element-visible — the inspector settings reach the page', () => {
|
|
301
|
+
it('a 50% threshold becomes the 0.5 the browser accepts, and "every time" keeps observing', async () => {
|
|
302
|
+
const code =
|
|
303
|
+
(await getWorkflowModule(
|
|
304
|
+
elementVisibleWorkflow({
|
|
305
|
+
nodeId: NODE_ID,
|
|
306
|
+
elementHtmlId: HTML_ID,
|
|
307
|
+
threshold: 50,
|
|
308
|
+
triggerOnce: false,
|
|
309
|
+
}),
|
|
310
|
+
HTML_ID
|
|
311
|
+
)) || ''
|
|
312
|
+
|
|
313
|
+
expect(code).toContain('Math.min(0.5, reach * 0.9)')
|
|
314
|
+
expect(code).not.toContain('threshold: 50 }')
|
|
315
|
+
// the only disconnect left is the cleanup's
|
|
316
|
+
expect(code.match(/__obs_wf_cookie_visible\.disconnect\(\)/g)).toHaveLength(1)
|
|
317
|
+
})
|
|
318
|
+
})
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// The two "scrolled past a point" triggers. `event-scene-scrolled-past` is a
|
|
2
|
+
// LIFECYCLE trigger bound to ONE element (the scroll scene): it listens for
|
|
3
|
+
// `tq-scene-point-passed` on the scene's track and is pruned from pages that
|
|
4
|
+
// do not render the scene. `event-page-scrolled-past` is page-scoped and
|
|
5
|
+
// watches the window's own scroll progress.
|
|
6
|
+
|
|
7
|
+
import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
|
|
8
|
+
|
|
9
|
+
const HTML_ID = 'thq_story_scene'
|
|
10
|
+
const NODE_ID = 'TQ__storyScene'
|
|
11
|
+
|
|
12
|
+
const workflowWith = (trigger: Record<string, unknown>) => ({
|
|
13
|
+
id: 'wf-scrolled-past',
|
|
14
|
+
name: 'Reveal offer',
|
|
15
|
+
trigger: { nodeId: 'trigger-scroll', ...trigger },
|
|
16
|
+
nodes: [
|
|
17
|
+
{
|
|
18
|
+
id: 'update-1',
|
|
19
|
+
type: 'state-update-local-state',
|
|
20
|
+
config: { property: 'offerShown', value: true },
|
|
21
|
+
stepNumber: 1,
|
|
22
|
+
label: 'Show offer',
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
edges: [{ id: 'e1', source: 'trigger-scroll', target: 'update-1' }],
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const sceneWorkflow = (config: Record<string, unknown>) =>
|
|
29
|
+
workflowWith({ type: 'event-scene-scrolled-past', scope: 'element', config })
|
|
30
|
+
|
|
31
|
+
const pageWorkflow = (config: Record<string, unknown>) =>
|
|
32
|
+
workflowWith({ type: 'event-page-scrolled-past', scope: 'page', config })
|
|
33
|
+
|
|
34
|
+
const jsxWithElementId = (elementId: string | null) => ({
|
|
35
|
+
type: 'chunk-type-ast',
|
|
36
|
+
name: 'jsx-component',
|
|
37
|
+
content: {
|
|
38
|
+
type: 'VariableDeclaration',
|
|
39
|
+
declarations: [
|
|
40
|
+
{
|
|
41
|
+
type: 'VariableDeclarator',
|
|
42
|
+
init: {
|
|
43
|
+
type: 'ArrowFunctionExpression',
|
|
44
|
+
body: {
|
|
45
|
+
type: 'BlockStatement',
|
|
46
|
+
body: [
|
|
47
|
+
{
|
|
48
|
+
type: 'ReturnStatement',
|
|
49
|
+
argument: {
|
|
50
|
+
type: 'JSXElement',
|
|
51
|
+
openingElement: {
|
|
52
|
+
type: 'JSXOpeningElement',
|
|
53
|
+
name: { type: 'JSXIdentifier', name: 'div' },
|
|
54
|
+
attributes: elementId
|
|
55
|
+
? [
|
|
56
|
+
{
|
|
57
|
+
type: 'JSXAttribute',
|
|
58
|
+
name: { type: 'JSXIdentifier', name: 'id' },
|
|
59
|
+
value: { type: 'StringLiteral', value: elementId },
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
: [],
|
|
63
|
+
},
|
|
64
|
+
children: [],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const getWorkflowModule = async (
|
|
76
|
+
workflow: any,
|
|
77
|
+
renderedElementId: string | null
|
|
78
|
+
): Promise<string | null> => {
|
|
79
|
+
const plugin = createNextWorkflowPlugin({ isPage: true })
|
|
80
|
+
const structure: any = {
|
|
81
|
+
uidl: {
|
|
82
|
+
name: 'Story',
|
|
83
|
+
outputOptions: { pageId: 'page_home', fileName: 'page_home' },
|
|
84
|
+
node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
|
|
85
|
+
stateDefinitions: { offerShown: { type: 'boolean', defaultValue: false } },
|
|
86
|
+
},
|
|
87
|
+
chunks: [jsxWithElementId(renderedElementId)],
|
|
88
|
+
options: { workflows: { workflows: { [workflow.id]: workflow }, customNodes: {} } },
|
|
89
|
+
dependencies: {},
|
|
90
|
+
}
|
|
91
|
+
await plugin(structure)
|
|
92
|
+
const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module')
|
|
93
|
+
return moduleChunk ? String(moduleChunk.content) : null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('event-scene-scrolled-past — generated listener', () => {
|
|
97
|
+
const module = (config: Record<string, unknown>, rendered: string | null = HTML_ID) =>
|
|
98
|
+
getWorkflowModule(sceneWorkflow(config), rendered)
|
|
99
|
+
|
|
100
|
+
it('listens on the scene track for the point the author picked', async () => {
|
|
101
|
+
const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID, point: 'three-quarters' })
|
|
102
|
+
expect(code).not.toBeNull()
|
|
103
|
+
expect(code).toContain(`document.getElementById('${HTML_ID}')`)
|
|
104
|
+
expect(code).toContain(".closest('[data-scene-track]')")
|
|
105
|
+
expect(code).toContain("addEventListener('tq-scene-point-passed'")
|
|
106
|
+
expect(code).toContain("if (detail.point !== 'three-quarters') { return; }")
|
|
107
|
+
expect(code).toContain(`elementId: '${HTML_ID}', point: 'three-quarters'`)
|
|
108
|
+
expect(code).toMatch(/if \(event\.target !== __spRoot_\w+\) \{ return; \}/)
|
|
109
|
+
expect(code).not.toContain(NODE_ID)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('falls back to halfway when no point was set', async () => {
|
|
113
|
+
const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID })
|
|
114
|
+
expect(code).toContain("if (detail.point !== 'half') { return; }")
|
|
115
|
+
expect(code).toMatch(/if \(__spStamp_\w+ >= 2\) \{/)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('fires once by default: detaches after the first pass and skips the listener when the stamp already covers it', async () => {
|
|
119
|
+
const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID, point: 'end' })
|
|
120
|
+
expect(code).toMatch(
|
|
121
|
+
/__spRoot_\w+\.removeEventListener\('tq-scene-point-passed', __spH_\w+\);\n\s+__spFire_/
|
|
122
|
+
)
|
|
123
|
+
expect(code).toMatch(/if \(__spStamp_\w+ < 4\) \{/)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('keeps listening when the author asked for every time', async () => {
|
|
127
|
+
const code = await module({
|
|
128
|
+
nodeId: NODE_ID,
|
|
129
|
+
elementHtmlId: HTML_ID,
|
|
130
|
+
point: 'quarter',
|
|
131
|
+
once: false,
|
|
132
|
+
})
|
|
133
|
+
expect(code).not.toMatch(
|
|
134
|
+
/__spRoot_\w+\.removeEventListener\('tq-scene-point-passed', __spH_\w+\);\n\s+__spFire_/
|
|
135
|
+
)
|
|
136
|
+
expect(code).not.toMatch(/if \(__spStamp_\w+ < 1\) \{/)
|
|
137
|
+
expect(code).toContain("addEventListener('tq-scene-point-passed'")
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('reads the stamped furthest point when it attaches after the pass', async () => {
|
|
141
|
+
const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID, point: 'half' })
|
|
142
|
+
expect(code).toContain("getAttribute('data-scene-point')")
|
|
143
|
+
expect(code).toMatch(/if \(__spStamp_\w+ >= 2\) \{\n\s+__spFire_\w+\(0\.5\);/)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('is pruned from a page that does not render the scene', async () => {
|
|
147
|
+
const code = await module({ nodeId: NODE_ID, elementHtmlId: HTML_ID }, 'some_other_element')
|
|
148
|
+
expect(code === null || !code.includes('tq-scene-point-passed')).toBe(true)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('event-page-scrolled-past — generated listener', () => {
|
|
153
|
+
const module = (config: Record<string, unknown>) => getWorkflowModule(pageWorkflow(config), null)
|
|
154
|
+
|
|
155
|
+
it('watches the window scroll progress for the point the author picked', async () => {
|
|
156
|
+
const code = await module({ pageId: 'page_home', point: 'three-quarters' })
|
|
157
|
+
expect(code).not.toBeNull()
|
|
158
|
+
expect(code).toContain("window.addEventListener('scroll', __ppOnScroll_")
|
|
159
|
+
expect(code).toContain("window.addEventListener('resize', __ppOnScroll_")
|
|
160
|
+
expect(code).toContain('document.documentElement.scrollHeight - window.innerHeight')
|
|
161
|
+
expect(code).toContain('if (progress < 0.75 - 0.000001)')
|
|
162
|
+
expect(code).toContain("point: 'three-quarters', progress: progress, url: window.location.href")
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('checks once on attach so an anchor link that opens past the point counts', async () => {
|
|
166
|
+
const code = await module({ pageId: 'page_home', point: 'half' })
|
|
167
|
+
expect(code).toMatch(/cleanups\.push\(__ppStop_\w+\);\n\s+__ppCheck_\w+\(\);/)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('stays off every page but the one it watches', async () => {
|
|
171
|
+
const code = await module({ pageId: 'page_pricing', point: 'half' })
|
|
172
|
+
expect(code === null || !code.includes('__ppOnScroll_')).toBe(true)
|
|
173
|
+
const everywhere = await module({ allPages: true, point: 'half' })
|
|
174
|
+
expect(everywhere).toContain('__ppOnScroll_')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('treats a page that cannot scroll as fully in view', async () => {
|
|
178
|
+
const code = await module({ pageId: 'page_home', point: 'end' })
|
|
179
|
+
expect(code).toContain('return max <= 0 ? 1 :')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('fires once by default and detaches; every time re-arms when the visitor scrolls back up', async () => {
|
|
183
|
+
const once = await module({ pageId: 'page_home', point: 'half' })
|
|
184
|
+
expect(once).toMatch(/__ppPassed_\w+ = true;\n\s+__ppStop_\w+\(\);/)
|
|
185
|
+
const every = await module({ pageId: 'page_home', point: 'half', once: false })
|
|
186
|
+
expect(every).not.toMatch(/__ppPassed_\w+ = true;\n\s+__ppStop_\w+\(\);/)
|
|
187
|
+
expect(every).toMatch(/\{ __ppPassed_\w+ = false; return; \}/)
|
|
188
|
+
})
|
|
189
|
+
})
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-route-generator.d.ts","sourceRoot":"","sources":["../../src/api-route-generator.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,aAAa,EAIb,sBAAsB,EACtB,sBAAsB,EACvB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAuD9C,MAAM,WAAW,yBAAyB;IACxC,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB;;;;OAIG;IACH,KAAK,CAAC,EAAE,YAAY,CAAA;CACrB;AA4BD,eAAO,MAAM,6BAA6B,YAC/B,eAAe,iBACT,MAAM,eACR,sBAAsB,YAC1B,yBAAyB,KACjC,MA+WF,CAAA;AAED,eAAO,MAAM,kBAAkB,YAAa,eAAe,KAAG,OAE7D,CAAA;AAED,eAAO,MAAM,sCAAsC,YACxC,eAAe,iBACT,MAAM,eACR,sBAAsB,YAC1B,yBAAyB,KACjC,MAsbF,CAAA;
|
|
1
|
+
{"version":3,"file":"api-route-generator.d.ts","sourceRoot":"","sources":["../../src/api-route-generator.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,aAAa,EAIb,sBAAsB,EACtB,sBAAsB,EACvB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAuD9C,MAAM,WAAW,yBAAyB;IACxC,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB;;;;OAIG;IACH,KAAK,CAAC,EAAE,YAAY,CAAA;CACrB;AA4BD,eAAO,MAAM,6BAA6B,YAC/B,eAAe,iBACT,MAAM,eACR,sBAAsB,YAC1B,yBAAyB,KACjC,MA+WF,CAAA;AAED,eAAO,MAAM,kBAAkB,YAAa,eAAe,KAAG,OAE7D,CAAA;AAED,eAAO,MAAM,sCAAsC,YACxC,eAAe,iBACT,MAAM,eACR,sBAAsB,YAC1B,yBAAyB,KACjC,MAsbF,CAAA;AA4JD,eAAO,MAAM,oBAAoB,aACrB,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,KACnD,MAsHF,CAAA;AAED,eAAO,MAAM,mBAAmB,eAClB,MAAM,aACP,MAAM,iBACF,MAAM,KACpB,MA4BF,CAAA;AAED,eAAO,MAAM,oBAAoB,aAAc,YAAY,KAAG,MAa7D,CAAA;AA8RD,eAAO,MAAM,+BAA+B,aAChC,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,KACnD,MA0OF,CAAA;AAED,eAAO,MAAM,uBAAuB,aAAc,YAAY,KAAG,MAiBhE,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,0BAA0B,cAC1B,aAAa,GAAG,SAAS,cACxB,MAAM,KACjB,MAAM,GAAG,IAYX,CAAA;AAED,eAAO,MAAM,mBAAmB,aAAc,YAAY,KAAG,MAAM,EAqBlE,CAAA"}
|