@dfosco/storyboard-react 2.0.0 → 2.2.0
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/package.json +2 -2
- package/src/Viewfinder.jsx +47 -214
- package/src/context.jsx +73 -20
- package/src/context.test.jsx +120 -15
- package/src/hooks/useRecord.js +33 -12
- package/src/hooks/useRecord.test.js +92 -1
- package/src/hooks/useScene.js +21 -11
- package/src/hooks/useScene.test.js +40 -13
- package/src/hooks/useSceneData.js +17 -11
- package/src/hooks/useSceneData.test.js +60 -32
- package/src/index.js +3 -1
- package/src/test-utils.js +8 -5
- package/src/vite/data-plugin.js +136 -18
- package/src/vite/data-plugin.test.js +135 -2
package/package.json
CHANGED
package/src/Viewfinder.jsx
CHANGED
|
@@ -1,227 +1,60 @@
|
|
|
1
1
|
|
|
2
|
-
import {
|
|
3
|
-
import { hash, resolveSceneRoute, getSceneMeta } from '@dfosco/storyboard-core'
|
|
4
|
-
import styles from './Viewfinder.module.css'
|
|
5
|
-
|
|
6
|
-
function formatSceneName(name) {
|
|
7
|
-
return name
|
|
8
|
-
.split('-')
|
|
9
|
-
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
10
|
-
.join(' ')
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function PlaceholderGraphic({ name }) {
|
|
14
|
-
const seed = hash(name)
|
|
15
|
-
const rects = []
|
|
16
|
-
|
|
17
|
-
for (let i = 0; i < 12; i++) {
|
|
18
|
-
const s = seed * (i + 1)
|
|
19
|
-
const x = (s * 7 + i * 31) % 320
|
|
20
|
-
const y = (s * 13 + i * 17) % 200
|
|
21
|
-
const w = 20 + (s * (i + 3)) % 80
|
|
22
|
-
const h = 8 + (s * (i + 7)) % 40
|
|
23
|
-
const opacity = 0.06 + ((s * (i + 2)) % 20) / 100
|
|
24
|
-
const fill = i % 3 === 0 ? 'var(--placeholder-accent)' : i % 3 === 1 ? 'var(--placeholder-fg)' : 'var(--placeholder-muted)'
|
|
25
|
-
|
|
26
|
-
rects.push(
|
|
27
|
-
<rect
|
|
28
|
-
key={i}
|
|
29
|
-
x={x}
|
|
30
|
-
y={y}
|
|
31
|
-
width={w}
|
|
32
|
-
height={h}
|
|
33
|
-
rx={2}
|
|
34
|
-
fill={fill}
|
|
35
|
-
opacity={opacity}
|
|
36
|
-
/>
|
|
37
|
-
)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const lines = []
|
|
41
|
-
for (let i = 0; i < 6; i++) {
|
|
42
|
-
const s = seed * (i + 5)
|
|
43
|
-
const y = 10 + (s % 180)
|
|
44
|
-
lines.push(
|
|
45
|
-
<line
|
|
46
|
-
key={`h${i}`}
|
|
47
|
-
x1={0}
|
|
48
|
-
y1={y}
|
|
49
|
-
x2={320}
|
|
50
|
-
y2={y}
|
|
51
|
-
stroke="var(--placeholder-grid)"
|
|
52
|
-
strokeWidth={0.5}
|
|
53
|
-
opacity={0.4}
|
|
54
|
-
/>
|
|
55
|
-
)
|
|
56
|
-
}
|
|
57
|
-
for (let i = 0; i < 8; i++) {
|
|
58
|
-
const s = seed * (i + 9)
|
|
59
|
-
const x = 10 + (s % 300)
|
|
60
|
-
lines.push(
|
|
61
|
-
<line
|
|
62
|
-
key={`v${i}`}
|
|
63
|
-
x1={x}
|
|
64
|
-
y1={0}
|
|
65
|
-
x2={x}
|
|
66
|
-
y2={200}
|
|
67
|
-
stroke="var(--placeholder-grid)"
|
|
68
|
-
strokeWidth={0.5}
|
|
69
|
-
opacity={0.3}
|
|
70
|
-
/>
|
|
71
|
-
)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return (
|
|
75
|
-
<svg viewBox="0 0 320 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
76
|
-
<rect width="320" height="200" fill="var(--placeholder-bg)" />
|
|
77
|
-
{lines}
|
|
78
|
-
{rects}
|
|
79
|
-
</svg>
|
|
80
|
-
)
|
|
81
|
-
}
|
|
2
|
+
import { useRef, useEffect } from 'react'
|
|
82
3
|
|
|
83
4
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const match = (basePath || '').match(/\/branch--([^/]+)\/?$/)
|
|
89
|
-
return match ? match[1] : 'main'
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Viewfinder — scene index and branch preview dashboard.
|
|
5
|
+
* Viewfinder — thin React wrapper around the Svelte Viewfinder component.
|
|
6
|
+
*
|
|
7
|
+
* Mounts the core Svelte Viewfinder into a container div and manages
|
|
8
|
+
* its lifecycle via React's useEffect.
|
|
94
9
|
*
|
|
95
10
|
* @param {Object} props
|
|
96
|
-
* @param {Record<string, unknown>} props.scenes - Scene index
|
|
97
|
-
* @param {Record<string, unknown>} props.
|
|
98
|
-
* @param {string} [props.
|
|
99
|
-
* @param {string} [props.
|
|
100
|
-
* @param {string} [props.
|
|
101
|
-
* @param {
|
|
102
|
-
* @param {boolean} [props.
|
|
11
|
+
* @param {Record<string, unknown>} [props.scenes] - Scene/flow index (deprecated, ignored — data comes from core)
|
|
12
|
+
* @param {Record<string, unknown>} [props.flows] - Flow index (deprecated, ignored — data comes from core)
|
|
13
|
+
* @param {Record<string, unknown>} [props.pageModules] - import.meta.glob result for page files
|
|
14
|
+
* @param {string} [props.basePath] - Base URL path
|
|
15
|
+
* @param {string} [props.title] - Header title
|
|
16
|
+
* @param {string} [props.subtitle] - Optional subtitle
|
|
17
|
+
* @param {boolean} [props.showThumbnails] - Show thumbnail previews
|
|
18
|
+
* @param {boolean} [props.hideDefaultFlow] - Hide the "default" flow from the "Other flows" section
|
|
103
19
|
*/
|
|
104
|
-
export default function Viewfinder({
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
const sceneNames = useMemo(() => {
|
|
108
|
-
const names = Object.keys(scenes)
|
|
109
|
-
return hideDefaultScene ? names.filter(n => n !== 'default') : names
|
|
110
|
-
}, [scenes, hideDefaultScene])
|
|
111
|
-
|
|
112
|
-
const knownRoutes = useMemo(() =>
|
|
113
|
-
Object.keys(pageModules)
|
|
114
|
-
.map(p => p.replace('/src/pages/', '').replace('.jsx', ''))
|
|
115
|
-
.filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder'),
|
|
116
|
-
[pageModules]
|
|
117
|
-
)
|
|
20
|
+
export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyboard', subtitle, showThumbnails = false, hideDefaultFlow, hideDefaultScene = false }) {
|
|
21
|
+
const containerRef = useRef(null)
|
|
22
|
+
const handleRef = useRef(null)
|
|
118
23
|
|
|
119
|
-
const
|
|
120
|
-
const base = basePath || '/storyboard-source/'
|
|
121
|
-
return base.replace(/\/branch--[^/]*\/$/, '/')
|
|
122
|
-
}, [basePath])
|
|
24
|
+
const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
|
|
123
25
|
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
{ branch: 'main', folder: '' },
|
|
128
|
-
{ branch: 'feat/comments-v2', folder: 'branch--feat-comments-v2' },
|
|
129
|
-
{ branch: 'fix/nav-overflow', folder: 'branch--fix-nav-overflow' },
|
|
130
|
-
], [])
|
|
26
|
+
const knownRoutes = Object.keys(pageModules)
|
|
27
|
+
.map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
|
|
28
|
+
.filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder')
|
|
131
29
|
|
|
132
30
|
useEffect(() => {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
31
|
+
if (!containerRef.current) return
|
|
32
|
+
|
|
33
|
+
let cancelled = false
|
|
34
|
+
|
|
35
|
+
import('@dfosco/storyboard-core/ui/viewfinder').then(({ mountViewfinder, unmountViewfinder }) => {
|
|
36
|
+
if (cancelled) return
|
|
37
|
+
// Ensure clean state for re-mounts
|
|
38
|
+
unmountViewfinder()
|
|
39
|
+
handleRef.current = mountViewfinder(containerRef.current, {
|
|
40
|
+
title,
|
|
41
|
+
subtitle,
|
|
42
|
+
basePath,
|
|
43
|
+
knownRoutes,
|
|
44
|
+
showThumbnails,
|
|
45
|
+
hideDefaultFlow: shouldHideDefault,
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return () => {
|
|
50
|
+
cancelled = true
|
|
51
|
+
if (handleRef.current) {
|
|
52
|
+
handleRef.current.destroy()
|
|
53
|
+
handleRef.current = null
|
|
54
|
+
}
|
|
144
55
|
}
|
|
145
|
-
}
|
|
56
|
+
}, [title, subtitle, basePath, showThumbnails, shouldHideDefault])
|
|
146
57
|
|
|
147
|
-
return
|
|
148
|
-
<div className={styles.container}>
|
|
149
|
-
<header className={styles.header}>
|
|
150
|
-
<div className={styles.headerTop}>
|
|
151
|
-
<div>
|
|
152
|
-
<h1 className={styles.title}>{title}</h1>
|
|
153
|
-
{subtitle && <p className={styles.subtitle}>{subtitle}</p>}
|
|
154
|
-
</div>
|
|
155
|
-
{branches && branches.length > 0 && (
|
|
156
|
-
<div className={styles.branchDropdown}>
|
|
157
|
-
<svg className={styles.branchIcon} width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
158
|
-
<path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.492 2.492 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
|
|
159
|
-
</svg>
|
|
160
|
-
<select
|
|
161
|
-
id="branch-select"
|
|
162
|
-
className={styles.branchSelect}
|
|
163
|
-
defaultValue=""
|
|
164
|
-
onChange={handleBranchChange}
|
|
165
|
-
aria-label="Switch branch"
|
|
166
|
-
>
|
|
167
|
-
<option value="" disabled>{currentBranch}</option>
|
|
168
|
-
{branches.map((b) => (
|
|
169
|
-
<option key={b.folder} value={b.folder}>
|
|
170
|
-
{b.branch}
|
|
171
|
-
</option>
|
|
172
|
-
))}
|
|
173
|
-
</select>
|
|
174
|
-
</div>
|
|
175
|
-
)}
|
|
176
|
-
</div>
|
|
177
|
-
<p className={styles.sceneCount}>
|
|
178
|
-
{sceneNames.length} scene{sceneNames.length !== 1 ? 's' : ''}
|
|
179
|
-
</p>
|
|
180
|
-
</header>
|
|
181
|
-
|
|
182
|
-
{sceneNames.length === 0 ? (
|
|
183
|
-
<p className={styles.empty}>No scenes found. Add a <code>*.scene.json</code> file to get started.</p>
|
|
184
|
-
) : (
|
|
185
|
-
<section>
|
|
186
|
-
{/* <h2 className={styles.sectionTitle}>Scenes</h2> */}
|
|
187
|
-
<div className={showThumbnails ? styles.grid : styles.list}>
|
|
188
|
-
{sceneNames.map((name) => {
|
|
189
|
-
const meta = getSceneMeta(name)
|
|
190
|
-
const displayName = meta?.title || meta?.name || formatSceneName(name)
|
|
191
|
-
return (
|
|
192
|
-
<a key={name} href={resolveSceneRoute(name, knownRoutes)} className={showThumbnails ? styles.card : styles.listItem}>
|
|
193
|
-
{showThumbnails && (
|
|
194
|
-
<div className={styles.thumbnail}>
|
|
195
|
-
<PlaceholderGraphic name={name} />
|
|
196
|
-
</div>
|
|
197
|
-
)}
|
|
198
|
-
<div className={styles.cardBody}>
|
|
199
|
-
<p className={styles.sceneName}>{displayName}</p>
|
|
200
|
-
{meta?.author && (() => {
|
|
201
|
-
const authors = Array.isArray(meta.author) ? meta.author : [meta.author]
|
|
202
|
-
return (
|
|
203
|
-
<div className={styles.author}>
|
|
204
|
-
<span className={styles.authorAvatars}>
|
|
205
|
-
{authors.map((a) => (
|
|
206
|
-
<img
|
|
207
|
-
key={a}
|
|
208
|
-
src={`https://github.com/${a}.png?size=32`}
|
|
209
|
-
alt={a}
|
|
210
|
-
className={styles.authorAvatar}
|
|
211
|
-
/>
|
|
212
|
-
))}
|
|
213
|
-
</span>
|
|
214
|
-
<span className={styles.authorName}>{authors.join(', ')}</span>
|
|
215
|
-
</div>
|
|
216
|
-
)
|
|
217
|
-
})()}
|
|
218
|
-
</div>
|
|
219
|
-
</a>
|
|
220
|
-
)
|
|
221
|
-
})}
|
|
222
|
-
</div>
|
|
223
|
-
</section>
|
|
224
|
-
)}
|
|
225
|
-
</div>
|
|
226
|
-
)
|
|
58
|
+
return <div ref={containerRef} style={{ minHeight: '100vh' }} />
|
|
227
59
|
}
|
|
60
|
+
|
package/src/context.jsx
CHANGED
|
@@ -2,16 +2,28 @@ import { useEffect, useMemo } from 'react'
|
|
|
2
2
|
import { useParams, useLocation } from 'react-router-dom'
|
|
3
3
|
// Side-effect import: seeds the core data index via init()
|
|
4
4
|
import 'virtual:storyboard-data-index'
|
|
5
|
-
import {
|
|
5
|
+
import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, isModesEnabled } from '@dfosco/storyboard-core'
|
|
6
6
|
import { StoryboardContext } from './StoryboardContext.js'
|
|
7
7
|
|
|
8
8
|
export { StoryboardContext }
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Derives
|
|
11
|
+
* Derives the top-level prototype name from a pathname.
|
|
12
|
+
* "/Dashboard" → "Dashboard", "/Dashboard/sub" → "Dashboard"
|
|
13
|
+
* "/posts/123" → "posts", "/" → null
|
|
14
|
+
*/
|
|
15
|
+
function getPrototypeName(pathname) {
|
|
16
|
+
const path = pathname.replace(/\/+$/, '') || '/'
|
|
17
|
+
if (path === '/') return null
|
|
18
|
+
const segments = path.split('/').filter(Boolean)
|
|
19
|
+
return segments[0] || null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Derives a flow name from a pathname.
|
|
12
24
|
* "/Overview" → "Overview", "/" → "index", "/nested/Page" → "Page"
|
|
13
25
|
*/
|
|
14
|
-
function
|
|
26
|
+
function getPageFlowName(pathname) {
|
|
15
27
|
const path = pathname.replace(/\/+$/, '') || '/'
|
|
16
28
|
if (path === '/') return 'index'
|
|
17
29
|
const last = path.split('/').pop()
|
|
@@ -19,51 +31,92 @@ function getPageSceneName(pathname) {
|
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
/**
|
|
22
|
-
* Provides loaded
|
|
23
|
-
* Reads the
|
|
24
|
-
* a matching
|
|
34
|
+
* Provides loaded flow data to the component tree.
|
|
35
|
+
* Reads the flow name from the ?flow= URL param (with ?scene= as alias),
|
|
36
|
+
* a matching flow file for the current page, or defaults to "default".
|
|
37
|
+
*
|
|
38
|
+
* Derives the prototype scope from the route and uses it to resolve
|
|
39
|
+
* scoped flow and record names (e.g. "Dashboard/default" for /Dashboard).
|
|
25
40
|
*
|
|
26
41
|
* Optionally merges record data when `recordName` and `recordParam` are provided.
|
|
27
|
-
* The matched record entry is injected under the "record" key in
|
|
42
|
+
* The matched record entry is injected under the "record" key in flow data.
|
|
28
43
|
*/
|
|
29
|
-
export default function StoryboardProvider({ sceneName, recordName, recordParam, children }) {
|
|
44
|
+
export default function StoryboardProvider({ flowName, sceneName, recordName, recordParam, children }) {
|
|
30
45
|
const location = useLocation()
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
46
|
+
const searchParams = new URLSearchParams(location.search)
|
|
47
|
+
const sceneParam = searchParams.get('flow') || searchParams.get('scene')
|
|
48
|
+
const prototypeName = getPrototypeName(location.pathname)
|
|
49
|
+
const pageFlow = getPageFlowName(location.pathname)
|
|
34
50
|
const params = useParams()
|
|
35
51
|
|
|
52
|
+
// Resolve flow name with prototype scoping
|
|
53
|
+
const activeFlowName = useMemo(() => {
|
|
54
|
+
const requested = sceneParam || flowName || sceneName
|
|
55
|
+
if (requested) {
|
|
56
|
+
return resolveFlowName(prototypeName, requested)
|
|
57
|
+
}
|
|
58
|
+
// 1. Page-specific flow (e.g., Example/Forms)
|
|
59
|
+
const scopedPageFlow = resolveFlowName(prototypeName, pageFlow)
|
|
60
|
+
if (flowExists(scopedPageFlow)) return scopedPageFlow
|
|
61
|
+
// 2. Prototype flow — named after the prototype folder (e.g., Example/example)
|
|
62
|
+
if (prototypeName) {
|
|
63
|
+
const protoFlow = resolveFlowName(prototypeName, prototypeName)
|
|
64
|
+
if (flowExists(protoFlow)) return protoFlow
|
|
65
|
+
}
|
|
66
|
+
// 3. Global default
|
|
67
|
+
return 'default'
|
|
68
|
+
}, [sceneParam, flowName, sceneName, prototypeName, pageFlow])
|
|
69
|
+
|
|
36
70
|
// Auto-install body class sync (sb-key--value classes on <body>)
|
|
37
71
|
useEffect(() => installBodyClassSync(), [])
|
|
38
72
|
|
|
73
|
+
// Mount design modes UI when enabled in storyboard.config.json
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!isModesEnabled()) return
|
|
76
|
+
|
|
77
|
+
let cleanup
|
|
78
|
+
import('@dfosco/storyboard-core/ui/design-modes')
|
|
79
|
+
.then(({ mountDesignModesUI }) => {
|
|
80
|
+
cleanup = mountDesignModesUI()
|
|
81
|
+
})
|
|
82
|
+
.catch(() => {
|
|
83
|
+
// Svelte UI not available — degrade gracefully
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return () => cleanup?.()
|
|
87
|
+
}, [])
|
|
88
|
+
|
|
39
89
|
const { data, error } = useMemo(() => {
|
|
40
90
|
try {
|
|
41
|
-
let
|
|
91
|
+
let flowData = loadFlow(activeFlowName)
|
|
42
92
|
|
|
43
|
-
// Merge record data if configured
|
|
93
|
+
// Merge record data if configured (with scoped resolution)
|
|
44
94
|
if (recordName && recordParam && params[recordParam]) {
|
|
45
|
-
const
|
|
95
|
+
const resolvedRecord = resolveRecordName(prototypeName, recordName)
|
|
96
|
+
const entry = findRecord(resolvedRecord, params[recordParam])
|
|
46
97
|
if (entry) {
|
|
47
|
-
|
|
98
|
+
flowData = deepMerge(flowData, { record: entry })
|
|
48
99
|
}
|
|
49
100
|
}
|
|
50
101
|
|
|
51
|
-
|
|
52
|
-
return { data:
|
|
102
|
+
setFlowClass(activeFlowName)
|
|
103
|
+
return { data: flowData, error: null }
|
|
53
104
|
} catch (err) {
|
|
54
105
|
return { data: null, error: err.message }
|
|
55
106
|
}
|
|
56
|
-
}, [
|
|
107
|
+
}, [activeFlowName, recordName, recordParam, params, prototypeName])
|
|
57
108
|
|
|
58
109
|
const value = {
|
|
59
110
|
data,
|
|
60
111
|
error,
|
|
61
112
|
loading: false,
|
|
62
|
-
|
|
113
|
+
flowName: activeFlowName,
|
|
114
|
+
sceneName: activeFlowName, // backward compat
|
|
115
|
+
prototypeName,
|
|
63
116
|
}
|
|
64
117
|
|
|
65
118
|
if (error) {
|
|
66
|
-
return <span style={{ color: 'var(--fgColor-danger, #f85149)' }}>Error loading
|
|
119
|
+
return <span style={{ color: 'var(--fgColor-danger, #f85149)' }}>Error loading flow: {error}</span>
|
|
67
120
|
}
|
|
68
121
|
|
|
69
122
|
return (
|
package/src/context.test.jsx
CHANGED
|
@@ -17,7 +17,7 @@ vi.mock('react-router-dom', async () => {
|
|
|
17
17
|
|
|
18
18
|
beforeEach(() => {
|
|
19
19
|
init({
|
|
20
|
-
|
|
20
|
+
flows: {
|
|
21
21
|
default: { title: 'Default Scene' },
|
|
22
22
|
other: { title: 'Other Scene' },
|
|
23
23
|
},
|
|
@@ -36,7 +36,7 @@ function ContextReader({ path }) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
describe('StoryboardProvider', () => {
|
|
39
|
-
it('renders children when
|
|
39
|
+
it('renders children when flow loads successfully', () => {
|
|
40
40
|
render(
|
|
41
41
|
<StoryboardProvider>
|
|
42
42
|
<span>child content</span>
|
|
@@ -45,7 +45,7 @@ describe('StoryboardProvider', () => {
|
|
|
45
45
|
expect(screen.getByText('child content')).toBeInTheDocument()
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
-
it('provides
|
|
48
|
+
it('provides flow data via context', () => {
|
|
49
49
|
render(
|
|
50
50
|
<StoryboardProvider>
|
|
51
51
|
<ContextReader path="title" />
|
|
@@ -54,7 +54,16 @@ describe('StoryboardProvider', () => {
|
|
|
54
54
|
expect(screen.getByTestId('ctx')).toHaveTextContent('Default Scene')
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
-
it('uses
|
|
57
|
+
it('uses flowName prop when provided', () => {
|
|
58
|
+
render(
|
|
59
|
+
<StoryboardProvider flowName="other">
|
|
60
|
+
<ContextReader path="title" />
|
|
61
|
+
</StoryboardProvider>,
|
|
62
|
+
)
|
|
63
|
+
expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('uses sceneName prop for backward compat', () => {
|
|
58
67
|
render(
|
|
59
68
|
<StoryboardProvider sceneName="other">
|
|
60
69
|
<ContextReader path="title" />
|
|
@@ -63,7 +72,7 @@ describe('StoryboardProvider', () => {
|
|
|
63
72
|
expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
|
|
64
73
|
})
|
|
65
74
|
|
|
66
|
-
it("falls back to 'default'
|
|
75
|
+
it("falls back to 'default' flow when no ?scene= param", () => {
|
|
67
76
|
render(
|
|
68
77
|
<StoryboardProvider>
|
|
69
78
|
<ContextReader path="title" />
|
|
@@ -72,22 +81,35 @@ describe('StoryboardProvider', () => {
|
|
|
72
81
|
expect(screen.getByTestId('ctx')).toHaveTextContent('Default Scene')
|
|
73
82
|
})
|
|
74
83
|
|
|
75
|
-
it('shows error message when
|
|
84
|
+
it('shows error message when flow fails to load', () => {
|
|
76
85
|
render(
|
|
77
|
-
<StoryboardProvider
|
|
86
|
+
<StoryboardProvider flowName="nonexistent">
|
|
78
87
|
<ContextReader />
|
|
79
88
|
</StoryboardProvider>,
|
|
80
89
|
)
|
|
81
|
-
expect(screen.getByText(/Error loading
|
|
90
|
+
expect(screen.getByText(/Error loading flow/)).toBeInTheDocument()
|
|
82
91
|
})
|
|
83
92
|
|
|
84
|
-
it('provides
|
|
93
|
+
it('provides flowName in context value', () => {
|
|
94
|
+
function FlowNameReader() {
|
|
95
|
+
const ctx = useContext(StoryboardContext)
|
|
96
|
+
return <span data-testid="name">{ctx?.flowName}</span>
|
|
97
|
+
}
|
|
98
|
+
render(
|
|
99
|
+
<StoryboardProvider flowName="other">
|
|
100
|
+
<FlowNameReader />
|
|
101
|
+
</StoryboardProvider>,
|
|
102
|
+
)
|
|
103
|
+
expect(screen.getByTestId('name')).toHaveTextContent('other')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('provides sceneName (backward compat) in context value', () => {
|
|
85
107
|
function SceneNameReader() {
|
|
86
108
|
const ctx = useContext(StoryboardContext)
|
|
87
109
|
return <span data-testid="name">{ctx?.sceneName}</span>
|
|
88
110
|
}
|
|
89
111
|
render(
|
|
90
|
-
<StoryboardProvider
|
|
112
|
+
<StoryboardProvider flowName="other">
|
|
91
113
|
<SceneNameReader />
|
|
92
114
|
</StoryboardProvider>,
|
|
93
115
|
)
|
|
@@ -107,9 +129,9 @@ describe('StoryboardProvider', () => {
|
|
|
107
129
|
expect(screen.getByTestId('loading')).toHaveTextContent('false')
|
|
108
130
|
})
|
|
109
131
|
|
|
110
|
-
it('auto-matches
|
|
132
|
+
it('auto-matches flow by pathname and resolves $ref data', () => {
|
|
111
133
|
init({
|
|
112
|
-
|
|
134
|
+
flows: {
|
|
113
135
|
default: { title: 'Default' },
|
|
114
136
|
Repositories: {
|
|
115
137
|
'$global': ['navigation'],
|
|
@@ -133,9 +155,9 @@ describe('StoryboardProvider', () => {
|
|
|
133
155
|
expect(screen.getByTestId('ctx')).toHaveTextContent('All repos')
|
|
134
156
|
})
|
|
135
157
|
|
|
136
|
-
it('resolves $ref objects when auto-matching
|
|
158
|
+
it('resolves $ref objects when auto-matching flow by pathname', () => {
|
|
137
159
|
init({
|
|
138
|
-
|
|
160
|
+
flows: {
|
|
139
161
|
default: { title: 'Default' },
|
|
140
162
|
Repositories: {
|
|
141
163
|
'$global': ['navigation'],
|
|
@@ -165,7 +187,18 @@ describe('StoryboardProvider', () => {
|
|
|
165
187
|
expect(screen.getByTestId('nav')).toHaveTextContent('Home,Repos')
|
|
166
188
|
})
|
|
167
189
|
|
|
168
|
-
it('reads ?
|
|
190
|
+
it('reads ?flow= param from location.search', () => {
|
|
191
|
+
mockUseLocation.mockReturnValue({ pathname: '/whatever', search: '?flow=other', hash: '' })
|
|
192
|
+
|
|
193
|
+
render(
|
|
194
|
+
<StoryboardProvider>
|
|
195
|
+
<ContextReader path="title" />
|
|
196
|
+
</StoryboardProvider>,
|
|
197
|
+
)
|
|
198
|
+
expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('reads ?scene= as alias for ?flow=', () => {
|
|
169
202
|
mockUseLocation.mockReturnValue({ pathname: '/whatever', search: '?scene=other', hash: '' })
|
|
170
203
|
|
|
171
204
|
render(
|
|
@@ -175,4 +208,76 @@ describe('StoryboardProvider', () => {
|
|
|
175
208
|
)
|
|
176
209
|
expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
|
|
177
210
|
})
|
|
211
|
+
|
|
212
|
+
it('prefers ?flow= over ?scene= when both present', () => {
|
|
213
|
+
mockUseLocation.mockReturnValue({ pathname: '/whatever', search: '?flow=other&scene=default', hash: '' })
|
|
214
|
+
|
|
215
|
+
render(
|
|
216
|
+
<StoryboardProvider>
|
|
217
|
+
<ContextReader path="title" />
|
|
218
|
+
</StoryboardProvider>,
|
|
219
|
+
)
|
|
220
|
+
expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('loads prototype flow for sub-pages when no page-specific flow exists', () => {
|
|
224
|
+
init({
|
|
225
|
+
flows: {
|
|
226
|
+
default: { title: 'Global Default' },
|
|
227
|
+
'Example/example': { title: 'Example Flow' },
|
|
228
|
+
},
|
|
229
|
+
objects: {},
|
|
230
|
+
records: {},
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// /Example/Forms — no Forms flow exists, should fall back to Example/example
|
|
234
|
+
mockUseLocation.mockReturnValue({ pathname: '/Example/Forms', search: '', hash: '' })
|
|
235
|
+
|
|
236
|
+
render(
|
|
237
|
+
<StoryboardProvider>
|
|
238
|
+
<ContextReader path="title" />
|
|
239
|
+
</StoryboardProvider>,
|
|
240
|
+
)
|
|
241
|
+
expect(screen.getByTestId('ctx')).toHaveTextContent('Example Flow')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('page-specific flow takes priority over prototype flow', () => {
|
|
245
|
+
init({
|
|
246
|
+
flows: {
|
|
247
|
+
default: { title: 'Global Default' },
|
|
248
|
+
'Example/example': { title: 'Example Flow' },
|
|
249
|
+
'Example/Forms': { title: 'Forms Flow' },
|
|
250
|
+
},
|
|
251
|
+
objects: {},
|
|
252
|
+
records: {},
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
mockUseLocation.mockReturnValue({ pathname: '/Example/Forms', search: '', hash: '' })
|
|
256
|
+
|
|
257
|
+
render(
|
|
258
|
+
<StoryboardProvider>
|
|
259
|
+
<ContextReader path="title" />
|
|
260
|
+
</StoryboardProvider>,
|
|
261
|
+
)
|
|
262
|
+
expect(screen.getByTestId('ctx')).toHaveTextContent('Forms Flow')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('falls to global default when no prototype flow exists', () => {
|
|
266
|
+
init({
|
|
267
|
+
flows: {
|
|
268
|
+
default: { title: 'Global Default' },
|
|
269
|
+
},
|
|
270
|
+
objects: {},
|
|
271
|
+
records: {},
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
mockUseLocation.mockReturnValue({ pathname: '/NoProto/SomePage', search: '', hash: '' })
|
|
275
|
+
|
|
276
|
+
render(
|
|
277
|
+
<StoryboardProvider>
|
|
278
|
+
<ContextReader path="title" />
|
|
279
|
+
</StoryboardProvider>,
|
|
280
|
+
)
|
|
281
|
+
expect(screen.getByTestId('ctx')).toHaveTextContent('Global Default')
|
|
282
|
+
})
|
|
178
283
|
})
|