@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 CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "2.1.0",
6
+ "@dfosco/storyboard-core": "2.2.0",
7
7
  "glob": "^11.0.0",
8
8
  "jsonc-parser": "^3.3.1"
9
9
  },
@@ -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.hideDefaultScene] - Hide the "default" flow
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: hideDefaultScene,
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, hideDefaultScene])
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 ?scene= URL param, the flowName prop,
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 sceneParam = new URLSearchParams(location.search).get('scene')
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()
@@ -187,7 +187,18 @@ describe('StoryboardProvider', () => {
187
187
  expect(screen.getByTestId('nav')).toHaveTextContent('Home,Repos')
188
188
  })
189
189
 
190
- it('reads ?scene= param from location.search', () => {
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: {
@@ -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} recordName - Record collection name (e.g. "posts")
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, recordName) {
27
+ function applyRecordOverrides(baseRecords, resolvedName, plainName) {
27
28
  const allParams = isHideMode() ? getAllShadows() : getAllParams()
28
- const prefix = `record.${recordName}.`
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 => k.startsWith(prefix))
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
+ })
@@ -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 ?scene= param
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.set('scene', name)
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
  }, [])