@dfosco/storyboard-react 2.1.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 +6 -4
- package/src/context.jsx +3 -2
- package/src/context.test.jsx +23 -1
- package/src/hooks/useRecord.js +19 -6
- package/src/hooks/useRecord.test.js +92 -1
- package/src/hooks/useScene.js +3 -2
package/package.json
CHANGED
package/src/Viewfinder.jsx
CHANGED
|
@@ -15,12 +15,14 @@ import { useRef, useEffect } from 'react'
|
|
|
15
15
|
* @param {string} [props.title] - Header title
|
|
16
16
|
* @param {string} [props.subtitle] - Optional subtitle
|
|
17
17
|
* @param {boolean} [props.showThumbnails] - Show thumbnail previews
|
|
18
|
-
* @param {boolean} [props.
|
|
18
|
+
* @param {boolean} [props.hideDefaultFlow] - Hide the "default" flow from the "Other flows" section
|
|
19
19
|
*/
|
|
20
|
-
export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyboard', subtitle, showThumbnails = false, hideDefaultScene = false }) {
|
|
20
|
+
export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyboard', subtitle, showThumbnails = false, hideDefaultFlow, hideDefaultScene = false }) {
|
|
21
21
|
const containerRef = useRef(null)
|
|
22
22
|
const handleRef = useRef(null)
|
|
23
23
|
|
|
24
|
+
const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
|
|
25
|
+
|
|
24
26
|
const knownRoutes = Object.keys(pageModules)
|
|
25
27
|
.map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
|
|
26
28
|
.filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder')
|
|
@@ -40,7 +42,7 @@ export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyb
|
|
|
40
42
|
basePath,
|
|
41
43
|
knownRoutes,
|
|
42
44
|
showThumbnails,
|
|
43
|
-
hideDefaultFlow:
|
|
45
|
+
hideDefaultFlow: shouldHideDefault,
|
|
44
46
|
})
|
|
45
47
|
})
|
|
46
48
|
|
|
@@ -51,7 +53,7 @@ export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyb
|
|
|
51
53
|
handleRef.current = null
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
|
-
}, [title, subtitle, basePath, showThumbnails,
|
|
56
|
+
}, [title, subtitle, basePath, showThumbnails, shouldHideDefault])
|
|
55
57
|
|
|
56
58
|
return <div ref={containerRef} style={{ minHeight: '100vh' }} />
|
|
57
59
|
}
|
package/src/context.jsx
CHANGED
|
@@ -32,7 +32,7 @@ function getPageFlowName(pathname) {
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Provides loaded flow data to the component tree.
|
|
35
|
-
* Reads the flow name from the ?
|
|
35
|
+
* Reads the flow name from the ?flow= URL param (with ?scene= as alias),
|
|
36
36
|
* a matching flow file for the current page, or defaults to "default".
|
|
37
37
|
*
|
|
38
38
|
* Derives the prototype scope from the route and uses it to resolve
|
|
@@ -43,7 +43,8 @@ function getPageFlowName(pathname) {
|
|
|
43
43
|
*/
|
|
44
44
|
export default function StoryboardProvider({ flowName, sceneName, recordName, recordParam, children }) {
|
|
45
45
|
const location = useLocation()
|
|
46
|
-
const
|
|
46
|
+
const searchParams = new URLSearchParams(location.search)
|
|
47
|
+
const sceneParam = searchParams.get('flow') || searchParams.get('scene')
|
|
47
48
|
const prototypeName = getPrototypeName(location.pathname)
|
|
48
49
|
const pageFlow = getPageFlowName(location.pathname)
|
|
49
50
|
const params = useParams()
|
package/src/context.test.jsx
CHANGED
|
@@ -187,7 +187,18 @@ describe('StoryboardProvider', () => {
|
|
|
187
187
|
expect(screen.getByTestId('nav')).toHaveTextContent('Home,Repos')
|
|
188
188
|
})
|
|
189
189
|
|
|
190
|
-
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=', () => {
|
|
191
202
|
mockUseLocation.mockReturnValue({ pathname: '/whatever', search: '?scene=other', hash: '' })
|
|
192
203
|
|
|
193
204
|
render(
|
|
@@ -198,6 +209,17 @@ describe('StoryboardProvider', () => {
|
|
|
198
209
|
expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
|
|
199
210
|
})
|
|
200
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
|
+
|
|
201
223
|
it('loads prototype flow for sub-pages when no page-specific flow exists', () => {
|
|
202
224
|
init({
|
|
203
225
|
flows: {
|
package/src/hooks/useRecord.js
CHANGED
|
@@ -20,15 +20,26 @@ import { StoryboardContext } from '../StoryboardContext.js'
|
|
|
20
20
|
* - Unknown ids create new entries appended to the array.
|
|
21
21
|
*
|
|
22
22
|
* @param {Array} baseRecords - The original record array (will be deep-cloned)
|
|
23
|
-
* @param {string}
|
|
23
|
+
* @param {string} resolvedName - Resolved (possibly scoped) record name (e.g. "security/rules")
|
|
24
|
+
* @param {string} [plainName] - Original unscoped record name (e.g. "rules"). Falls back to resolvedName.
|
|
24
25
|
* @returns {Array} Merged array
|
|
25
26
|
*/
|
|
26
|
-
function applyRecordOverrides(baseRecords,
|
|
27
|
+
function applyRecordOverrides(baseRecords, resolvedName, plainName) {
|
|
27
28
|
const allParams = isHideMode() ? getAllShadows() : getAllParams()
|
|
28
|
-
|
|
29
|
+
|
|
30
|
+
// Check both the resolved (scoped) prefix and the plain (unscoped) prefix.
|
|
31
|
+
// Callers write overrides with the plain name, but the data index resolves
|
|
32
|
+
// to the scoped name — we need to match both so overrides are not silently
|
|
33
|
+
// dropped for prototype-scoped records.
|
|
34
|
+
const resolvedPrefix = `record.${resolvedName}.`
|
|
35
|
+
const plainPrefix = plainName && plainName !== resolvedName
|
|
36
|
+
? `record.${plainName}.`
|
|
37
|
+
: null
|
|
29
38
|
|
|
30
39
|
// Collect only the params that target this record
|
|
31
|
-
const overrideKeys = Object.keys(allParams).filter(k =>
|
|
40
|
+
const overrideKeys = Object.keys(allParams).filter(k =>
|
|
41
|
+
k.startsWith(resolvedPrefix) || (plainPrefix && k.startsWith(plainPrefix))
|
|
42
|
+
)
|
|
32
43
|
if (overrideKeys.length === 0) return baseRecords
|
|
33
44
|
|
|
34
45
|
const records = deepClone(baseRecords)
|
|
@@ -37,6 +48,8 @@ function applyRecordOverrides(baseRecords, recordName) {
|
|
|
37
48
|
// key format: record.{name}.{entryId}.{field...}
|
|
38
49
|
const byEntryId = {}
|
|
39
50
|
for (const key of overrideKeys) {
|
|
51
|
+
// Determine which prefix matched to slice correctly
|
|
52
|
+
const prefix = key.startsWith(resolvedPrefix) ? resolvedPrefix : plainPrefix
|
|
40
53
|
const rest = key.slice(prefix.length) // "{entryId}.{field...}"
|
|
41
54
|
const dotIdx = rest.indexOf('.')
|
|
42
55
|
if (dotIdx === -1) continue // no field path — skip
|
|
@@ -104,7 +117,7 @@ export function useRecord(recordName, paramName = 'id') {
|
|
|
104
117
|
try {
|
|
105
118
|
const resolvedName = resolveRecordName(prototypeName, recordName)
|
|
106
119
|
const base = loadRecord(resolvedName)
|
|
107
|
-
const merged = applyRecordOverrides(base, resolvedName)
|
|
120
|
+
const merged = applyRecordOverrides(base, resolvedName, recordName)
|
|
108
121
|
return merged.find(e => e[paramName] === paramValue) ?? null
|
|
109
122
|
} catch (err) {
|
|
110
123
|
console.error(`[useRecord] ${err.message}`)
|
|
@@ -136,7 +149,7 @@ export function useRecords(recordName) {
|
|
|
136
149
|
try {
|
|
137
150
|
const resolvedName = resolveRecordName(prototypeName, recordName)
|
|
138
151
|
const base = loadRecord(resolvedName)
|
|
139
|
-
return applyRecordOverrides(base, resolvedName)
|
|
152
|
+
return applyRecordOverrides(base, resolvedName, recordName)
|
|
140
153
|
} catch (err) {
|
|
141
154
|
console.error(`[useRecords] ${err.message}`)
|
|
142
155
|
return []
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import React from 'react'
|
|
1
2
|
import { renderHook, act } from '@testing-library/react'
|
|
2
3
|
import { seedTestData, TEST_RECORDS } from '../../test-utils.js'
|
|
3
|
-
import { activateHideMode, setShadow } from '@dfosco/storyboard-core'
|
|
4
|
+
import { activateHideMode, setShadow, init } from '@dfosco/storyboard-core'
|
|
5
|
+
import { StoryboardContext } from '../StoryboardContext.js'
|
|
4
6
|
|
|
5
7
|
vi.mock('react-router-dom', async () => {
|
|
6
8
|
const actual = await vi.importActual('react-router-dom')
|
|
@@ -15,6 +17,20 @@ beforeEach(() => {
|
|
|
15
17
|
useParams.mockReturnValue({})
|
|
16
18
|
})
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Create a wrapper that provides StoryboardContext with a prototypeName,
|
|
22
|
+
* used for testing scoped (prototype-level) records.
|
|
23
|
+
*/
|
|
24
|
+
function createPrototypeWrapper(prototypeName) {
|
|
25
|
+
return function Wrapper({ children }) {
|
|
26
|
+
return React.createElement(
|
|
27
|
+
StoryboardContext.Provider,
|
|
28
|
+
{ value: { data: {}, prototypeName } },
|
|
29
|
+
children,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
// ── useRecord ──
|
|
19
35
|
|
|
20
36
|
describe('useRecord', () => {
|
|
@@ -128,3 +144,78 @@ describe('useRecords (hide mode)', () => {
|
|
|
128
144
|
expect(newPost.title).toBe('New Shadow')
|
|
129
145
|
})
|
|
130
146
|
})
|
|
147
|
+
|
|
148
|
+
// ── Scoped (prototype) records ──
|
|
149
|
+
|
|
150
|
+
const SCOPED_RECORDS = {
|
|
151
|
+
'security/rules': [
|
|
152
|
+
{ id: 'constant-condition', title: 'Constant Condition', state: 'open' },
|
|
153
|
+
{ id: 'unused-var', title: 'Unused Variable', state: 'open' },
|
|
154
|
+
],
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function seedScopedData() {
|
|
158
|
+
init({
|
|
159
|
+
flows: {},
|
|
160
|
+
objects: {},
|
|
161
|
+
records: SCOPED_RECORDS,
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
describe('useRecords (scoped records)', () => {
|
|
166
|
+
beforeEach(() => {
|
|
167
|
+
seedScopedData()
|
|
168
|
+
window.location.hash = ''
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('applies overrides written with the plain (unscoped) record name', () => {
|
|
172
|
+
// Callers write: record.rules.constant-condition.state=dismissed
|
|
173
|
+
// Reader resolves to "security/rules" — this was the bug
|
|
174
|
+
window.location.hash = 'record.rules.constant-condition.state=dismissed'
|
|
175
|
+
|
|
176
|
+
const wrapper = createPrototypeWrapper('security')
|
|
177
|
+
const { result } = renderHook(() => useRecords('rules'), { wrapper })
|
|
178
|
+
|
|
179
|
+
const rule = result.current.find(e => e.id === 'constant-condition')
|
|
180
|
+
expect(rule.state).toBe('dismissed')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('applies overrides written with the resolved (scoped) record name', () => {
|
|
184
|
+
window.location.hash = 'record.security/rules.constant-condition.state=dismissed'
|
|
185
|
+
|
|
186
|
+
const wrapper = createPrototypeWrapper('security')
|
|
187
|
+
const { result } = renderHook(() => useRecords('rules'), { wrapper })
|
|
188
|
+
|
|
189
|
+
const rule = result.current.find(e => e.id === 'constant-condition')
|
|
190
|
+
expect(rule.state).toBe('dismissed')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('merges overrides from both plain and scoped prefixes', () => {
|
|
194
|
+
window.location.hash =
|
|
195
|
+
'record.rules.constant-condition.state=dismissed' +
|
|
196
|
+
'&record.security/rules.unused-var.state=resolved'
|
|
197
|
+
|
|
198
|
+
const wrapper = createPrototypeWrapper('security')
|
|
199
|
+
const { result } = renderHook(() => useRecords('rules'), { wrapper })
|
|
200
|
+
|
|
201
|
+
expect(result.current.find(e => e.id === 'constant-condition').state).toBe('dismissed')
|
|
202
|
+
expect(result.current.find(e => e.id === 'unused-var').state).toBe('resolved')
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('useRecord (scoped records)', () => {
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
seedScopedData()
|
|
209
|
+
window.location.hash = ''
|
|
210
|
+
useParams.mockReturnValue({ id: 'constant-condition' })
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('applies overrides written with the plain (unscoped) record name', () => {
|
|
214
|
+
window.location.hash = 'record.rules.constant-condition.state=dismissed'
|
|
215
|
+
|
|
216
|
+
const wrapper = createPrototypeWrapper('security')
|
|
217
|
+
const { result } = renderHook(() => useRecord('rules'), { wrapper })
|
|
218
|
+
|
|
219
|
+
expect(result.current.state).toBe('dismissed')
|
|
220
|
+
})
|
|
221
|
+
})
|
package/src/hooks/useScene.js
CHANGED
|
@@ -6,7 +6,7 @@ import { StoryboardContext } from '../StoryboardContext.js'
|
|
|
6
6
|
*
|
|
7
7
|
* @returns {{ flowName: string, switchFlow: (name: string) => void }}
|
|
8
8
|
* - flowName – current active flow (e.g. "default")
|
|
9
|
-
* - switchFlow – navigate to a different flow by updating ?
|
|
9
|
+
* - switchFlow – navigate to a different flow by updating ?flow= param
|
|
10
10
|
*/
|
|
11
11
|
export function useFlow() {
|
|
12
12
|
const context = useContext(StoryboardContext)
|
|
@@ -16,7 +16,8 @@ export function useFlow() {
|
|
|
16
16
|
|
|
17
17
|
const switchFlow = useCallback((name) => {
|
|
18
18
|
const url = new URL(window.location.href)
|
|
19
|
-
url.searchParams.
|
|
19
|
+
url.searchParams.delete('scene')
|
|
20
|
+
url.searchParams.set('flow', name)
|
|
20
21
|
// Preserve hash params across flow switches
|
|
21
22
|
window.location.href = url.toString()
|
|
22
23
|
}, [])
|