@dfosco/storyboard-react 1.24.0 → 2.0.0-beta.1

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.
@@ -1,11 +1,12 @@
1
- import { useMemo, useSyncExternalStore } from 'react'
1
+ import { useContext, useMemo, useSyncExternalStore } from 'react'
2
2
  import { useParams } from 'react-router-dom'
3
- import { loadRecord } from '@dfosco/storyboard-core'
3
+ import { loadRecord, resolveRecordName } from '@dfosco/storyboard-core'
4
4
  import { deepClone, setByPath } from '@dfosco/storyboard-core'
5
5
  import { getAllParams } from '@dfosco/storyboard-core'
6
6
  import { isHideMode, getAllShadows } from '@dfosco/storyboard-core'
7
7
  import { subscribeToHash, getHashSnapshot } from '@dfosco/storyboard-core'
8
8
  import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
9
+ import { StoryboardContext } from '../StoryboardContext.js'
9
10
 
10
11
  /**
11
12
  * Collect overrides for a record and merge them into the base array.
@@ -91,6 +92,8 @@ function applyRecordOverrides(baseRecords, recordName) {
91
92
  export function useRecord(recordName, paramName = 'id') {
92
93
  const params = useParams()
93
94
  const paramValue = params[paramName]
95
+ const context = useContext(StoryboardContext)
96
+ const prototypeName = context?.prototypeName ?? null
94
97
 
95
98
  // Re-render on hash or localStorage changes so overrides are reactive
96
99
  const hashString = useSyncExternalStore(subscribeToHash, getHashSnapshot)
@@ -99,14 +102,15 @@ export function useRecord(recordName, paramName = 'id') {
99
102
  return useMemo(() => {
100
103
  if (!paramValue) return null
101
104
  try {
102
- const base = loadRecord(recordName)
103
- const merged = applyRecordOverrides(base, recordName)
105
+ const resolvedName = resolveRecordName(prototypeName, recordName)
106
+ const base = loadRecord(resolvedName)
107
+ const merged = applyRecordOverrides(base, resolvedName)
104
108
  return merged.find(e => e[paramName] === paramValue) ?? null
105
109
  } catch (err) {
106
110
  console.error(`[useRecord] ${err.message}`)
107
111
  return null
108
112
  }
109
- }, [recordName, paramName, paramValue, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
113
+ }, [recordName, paramName, paramValue, prototypeName, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
110
114
  }
111
115
 
112
116
  /**
@@ -121,17 +125,21 @@ export function useRecord(recordName, paramName = 'id') {
121
125
  * const allPosts = useRecords('posts')
122
126
  */
123
127
  export function useRecords(recordName) {
128
+ const context = useContext(StoryboardContext)
129
+ const prototypeName = context?.prototypeName ?? null
130
+
124
131
  // Re-render on hash or localStorage changes so overrides are reactive
125
132
  const hashString = useSyncExternalStore(subscribeToHash, getHashSnapshot)
126
133
  const storageString = useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
127
134
 
128
135
  return useMemo(() => {
129
136
  try {
130
- const base = loadRecord(recordName)
131
- return applyRecordOverrides(base, recordName)
137
+ const resolvedName = resolveRecordName(prototypeName, recordName)
138
+ const base = loadRecord(resolvedName)
139
+ return applyRecordOverrides(base, resolvedName)
132
140
  } catch (err) {
133
141
  console.error(`[useRecords] ${err.message}`)
134
142
  return []
135
143
  }
136
- }, [recordName, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
144
+ }, [recordName, prototypeName, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
137
145
  }
@@ -2,27 +2,36 @@ import { useContext, useCallback } from 'react'
2
2
  import { StoryboardContext } from '../StoryboardContext.js'
3
3
 
4
4
  /**
5
- * Read the current scene name and programmatically switch scenes.
5
+ * Read the current flow name and programmatically switch flows.
6
6
  *
7
- * @returns {{ sceneName: string, switchScene: (name: string) => void }}
8
- * - sceneName – current active scene (e.g. "default")
9
- * - switchScene – navigate to a different scene by updating ?scene= param
7
+ * @returns {{ flowName: string, switchFlow: (name: string) => void }}
8
+ * - flowName – current active flow (e.g. "default")
9
+ * - switchFlow – navigate to a different flow by updating ?scene= param
10
10
  */
11
- export function useScene() {
11
+ export function useFlow() {
12
12
  const context = useContext(StoryboardContext)
13
13
  if (context === null) {
14
- throw new Error('useScene must be used within a <StoryboardProvider>')
14
+ throw new Error('useFlow must be used within a <StoryboardProvider>')
15
15
  }
16
16
 
17
- const switchScene = useCallback((name) => {
17
+ const switchFlow = useCallback((name) => {
18
18
  const url = new URL(window.location.href)
19
19
  url.searchParams.set('scene', name)
20
- // Preserve hash params across scene switches
20
+ // Preserve hash params across flow switches
21
21
  window.location.href = url.toString()
22
22
  }, [])
23
23
 
24
24
  return {
25
- sceneName: context.sceneName,
26
- switchScene,
25
+ flowName: context.flowName,
26
+ switchFlow,
27
+ }
28
+ }
29
+
30
+ /** @deprecated Use useFlow() */
31
+ export function useScene() {
32
+ const { flowName, switchFlow } = useFlow()
33
+ return {
34
+ sceneName: flowName,
35
+ switchScene: switchFlow,
27
36
  }
28
37
  }
@@ -1,39 +1,66 @@
1
1
  import { renderHook } from '@testing-library/react'
2
+ import { useFlow } from './useScene.js'
2
3
  import { useScene } from './useScene.js'
3
- import { seedTestData, createWrapper, TEST_SCENES } from '../test-utils.js'
4
+ import { seedTestData, createWrapper, TEST_FLOWS } from '../test-utils.js'
4
5
 
5
- const sceneData = TEST_SCENES.default
6
+ const flowData = TEST_FLOWS.default
6
7
 
7
8
  beforeEach(() => {
8
9
  seedTestData()
9
10
  })
10
11
 
11
- describe('useScene', () => {
12
+ describe('useFlow', () => {
13
+ it('returns { flowName, switchFlow }', () => {
14
+ const { result } = renderHook(() => useFlow(), {
15
+ wrapper: createWrapper(flowData),
16
+ })
17
+ expect(result.current).toHaveProperty('flowName')
18
+ expect(result.current).toHaveProperty('switchFlow')
19
+ })
20
+
21
+ it('flowName matches the value from context', () => {
22
+ const { result } = renderHook(() => useFlow(), {
23
+ wrapper: createWrapper(flowData, 'other'),
24
+ })
25
+ expect(result.current.flowName).toBe('other')
26
+ })
27
+
28
+ it('switchFlow is a function', () => {
29
+ const { result } = renderHook(() => useFlow(), {
30
+ wrapper: createWrapper(flowData),
31
+ })
32
+ expect(typeof result.current.switchFlow).toBe('function')
33
+ })
34
+
35
+ it('throws when used outside StoryboardProvider', () => {
36
+ expect(() => {
37
+ renderHook(() => useFlow())
38
+ }).toThrow('useFlow must be used within a <StoryboardProvider>')
39
+ })
40
+ })
41
+
42
+ // ── useScene (deprecated alias) ──
43
+
44
+ describe('useScene (deprecated alias)', () => {
12
45
  it('returns { sceneName, switchScene }', () => {
13
46
  const { result } = renderHook(() => useScene(), {
14
- wrapper: createWrapper(sceneData),
47
+ wrapper: createWrapper(flowData),
15
48
  })
16
49
  expect(result.current).toHaveProperty('sceneName')
17
50
  expect(result.current).toHaveProperty('switchScene')
18
51
  })
19
52
 
20
- it('sceneName matches the value from context', () => {
53
+ it('sceneName matches the flow name from context', () => {
21
54
  const { result } = renderHook(() => useScene(), {
22
- wrapper: createWrapper(sceneData, 'other'),
55
+ wrapper: createWrapper(flowData, 'other'),
23
56
  })
24
57
  expect(result.current.sceneName).toBe('other')
25
58
  })
26
59
 
27
60
  it('switchScene is a function', () => {
28
61
  const { result } = renderHook(() => useScene(), {
29
- wrapper: createWrapper(sceneData),
62
+ wrapper: createWrapper(flowData),
30
63
  })
31
64
  expect(typeof result.current.switchScene).toBe('function')
32
65
  })
33
-
34
- it('throws when used outside StoryboardProvider', () => {
35
- expect(() => {
36
- renderHook(() => useScene())
37
- }).toThrow('useScene must be used within a <StoryboardProvider>')
38
- })
39
66
  })
@@ -7,24 +7,24 @@ import { isHideMode, getShadow, getAllShadows } from '@dfosco/storyboard-core'
7
7
  import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
8
8
 
9
9
  /**
10
- * Access scene data by dot-notation path.
11
- * Hash params override scene data — both exact matches and nested paths.
10
+ * Access flow data by dot-notation path.
11
+ * Hash params override flow data — both exact matches and nested paths.
12
12
  *
13
13
  * Examples:
14
- * useSceneData('user.name') with #user.name=Alice → "Alice"
15
- * useSceneData('repositories') with #repositories.0.name=Foo
14
+ * useFlowData('user.name') with #user.name=Alice → "Alice"
15
+ * useFlowData('repositories') with #repositories.0.name=Foo
16
16
  * → deep clone of repositories array with [0].name overridden to "Foo"
17
17
  *
18
18
  * @param {string} [path] - Dot-notation path (e.g. 'user.profile.name').
19
- * Omit to get the entire scene object.
19
+ * Omit to get the entire flow object.
20
20
  * @returns {*} The resolved value. Returns {} if path is missing after loading.
21
21
  * @throws If used outside a StoryboardProvider.
22
22
  */
23
- export function useSceneData(path) {
23
+ export function useFlowData(path) {
24
24
  const context = useContext(StoryboardContext)
25
25
 
26
26
  if (context === null) {
27
- throw new Error('useSceneData must be used within a <StoryboardProvider>')
27
+ throw new Error('useFlowData must be used within a <StoryboardProvider>')
28
28
  }
29
29
 
30
30
  const { data, loading, error } = context
@@ -73,7 +73,7 @@ export function useSceneData(path) {
73
73
  }
74
74
 
75
75
  if (sceneValue === undefined) {
76
- console.warn(`[useSceneData] Path "${path}" not found in scene data.`)
76
+ console.warn(`[useFlowData] Path "${path}" not found in flow data.`)
77
77
  return {}
78
78
  }
79
79
 
@@ -83,15 +83,21 @@ export function useSceneData(path) {
83
83
  return result
84
84
  }
85
85
 
86
+ /** @deprecated Use useFlowData() */
87
+ export const useSceneData = useFlowData
88
+
86
89
  /**
87
- * Returns true while scene data is still loading.
90
+ * Returns true while flow data is still loading.
88
91
  */
89
- export function useSceneLoading() {
92
+ export function useFlowLoading() {
90
93
  const context = useContext(StoryboardContext)
91
94
 
92
95
  if (context === null) {
93
- throw new Error('useSceneLoading must be used within a <StoryboardProvider>')
96
+ throw new Error('useFlowLoading must be used within a <StoryboardProvider>')
94
97
  }
95
98
 
96
99
  return context.loading
97
100
  }
101
+
102
+ /** @deprecated Use useFlowLoading() */
103
+ export const useSceneLoading = useFlowLoading
@@ -1,38 +1,38 @@
1
1
  import { renderHook } from '@testing-library/react'
2
- import { useSceneData, useSceneLoading } from './useSceneData.js'
3
- import { seedTestData, createWrapper, TEST_SCENES } from '../test-utils.js'
2
+ import { useFlowData, useFlowLoading, useSceneData, useSceneLoading } from './useSceneData.js'
3
+ import { seedTestData, createWrapper, TEST_FLOWS } from '../test-utils.js'
4
4
 
5
- const sceneData = TEST_SCENES.default
5
+ const flowData = TEST_FLOWS.default
6
6
 
7
7
  beforeEach(() => {
8
8
  seedTestData()
9
9
  })
10
10
 
11
- describe('useSceneData', () => {
12
- it('returns entire scene object when no path given', () => {
13
- const { result } = renderHook(() => useSceneData(), {
14
- wrapper: createWrapper(sceneData),
11
+ describe('useFlowData', () => {
12
+ it('returns entire flow object when no path given', () => {
13
+ const { result } = renderHook(() => useFlowData(), {
14
+ wrapper: createWrapper(flowData),
15
15
  })
16
- expect(result.current).toEqual(sceneData)
16
+ expect(result.current).toEqual(flowData)
17
17
  })
18
18
 
19
19
  it('returns nested value by dot-notation path', () => {
20
- const { result } = renderHook(() => useSceneData('user.name'), {
21
- wrapper: createWrapper(sceneData),
20
+ const { result } = renderHook(() => useFlowData('user.name'), {
21
+ wrapper: createWrapper(flowData),
22
22
  })
23
23
  expect(result.current).toBe('Jane')
24
24
  })
25
25
 
26
26
  it('returns deep nested value', () => {
27
- const { result } = renderHook(() => useSceneData('user.profile.bio'), {
28
- wrapper: createWrapper(sceneData),
27
+ const { result } = renderHook(() => useFlowData('user.profile.bio'), {
28
+ wrapper: createWrapper(flowData),
29
29
  })
30
30
  expect(result.current).toBe('Dev')
31
31
  })
32
32
 
33
33
  it('returns array by path', () => {
34
- const { result } = renderHook(() => useSceneData('projects'), {
35
- wrapper: createWrapper(sceneData),
34
+ const { result } = renderHook(() => useFlowData('projects'), {
35
+ wrapper: createWrapper(flowData),
36
36
  })
37
37
  expect(result.current).toEqual([
38
38
  { id: 1, name: 'alpha' },
@@ -41,16 +41,16 @@ describe('useSceneData', () => {
41
41
  })
42
42
 
43
43
  it('returns array element by index path', () => {
44
- const { result } = renderHook(() => useSceneData('projects.0'), {
45
- wrapper: createWrapper(sceneData),
44
+ const { result } = renderHook(() => useFlowData('projects.0'), {
45
+ wrapper: createWrapper(flowData),
46
46
  })
47
47
  expect(result.current).toEqual({ id: 1, name: 'alpha' })
48
48
  })
49
49
 
50
50
  it('returns empty object and warns for missing path', () => {
51
51
  const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
52
- const { result } = renderHook(() => useSceneData('nonexistent.path'), {
53
- wrapper: createWrapper(sceneData),
52
+ const { result } = renderHook(() => useFlowData('nonexistent.path'), {
53
+ wrapper: createWrapper(flowData),
54
54
  })
55
55
  expect(result.current).toEqual({})
56
56
  expect(spy).toHaveBeenCalledWith(
@@ -61,48 +61,76 @@ describe('useSceneData', () => {
61
61
 
62
62
  it('throws when used outside StoryboardProvider', () => {
63
63
  expect(() => {
64
- renderHook(() => useSceneData())
65
- }).toThrow('useSceneData must be used within a <StoryboardProvider>')
64
+ renderHook(() => useFlowData())
65
+ }).toThrow('useFlowData must be used within a <StoryboardProvider>')
66
66
  })
67
67
 
68
68
  it('returns hash override value when param matches path', () => {
69
69
  window.location.hash = '#user.name=Alice'
70
- const { result } = renderHook(() => useSceneData('user.name'), {
71
- wrapper: createWrapper(sceneData),
70
+ const { result } = renderHook(() => useFlowData('user.name'), {
71
+ wrapper: createWrapper(flowData),
72
72
  })
73
73
  expect(result.current).toBe('Alice')
74
74
  })
75
75
 
76
76
  it('applies child overrides to arrays', () => {
77
77
  window.location.hash = '#projects.0.name=gamma'
78
- const { result } = renderHook(() => useSceneData('projects'), {
79
- wrapper: createWrapper(sceneData),
78
+ const { result } = renderHook(() => useFlowData('projects'), {
79
+ wrapper: createWrapper(flowData),
80
80
  })
81
81
  expect(result.current[0].name).toBe('gamma')
82
82
  expect(result.current[1].name).toBe('beta')
83
83
  })
84
84
 
85
- it('returns full scene with all overrides applied when no path', () => {
85
+ it('returns full flow with all overrides applied when no path', () => {
86
86
  window.location.hash = '#user.name=Alice'
87
- const { result } = renderHook(() => useSceneData(), {
88
- wrapper: createWrapper(sceneData),
87
+ const { result } = renderHook(() => useFlowData(), {
88
+ wrapper: createWrapper(flowData),
89
89
  })
90
90
  expect(result.current.user.name).toBe('Alice')
91
91
  expect(result.current.settings.theme).toBe('dark')
92
92
  })
93
93
  })
94
94
 
95
- describe('useSceneLoading', () => {
95
+ describe('useFlowLoading', () => {
96
96
  it('returns false when not loading', () => {
97
- const { result } = renderHook(() => useSceneLoading(), {
98
- wrapper: createWrapper(sceneData),
97
+ const { result } = renderHook(() => useFlowLoading(), {
98
+ wrapper: createWrapper(flowData),
99
99
  })
100
100
  expect(result.current).toBe(false)
101
101
  })
102
102
 
103
103
  it('throws when used outside StoryboardProvider', () => {
104
104
  expect(() => {
105
- renderHook(() => useSceneLoading())
106
- }).toThrow('useSceneLoading must be used within a <StoryboardProvider>')
105
+ renderHook(() => useFlowLoading())
106
+ }).toThrow('useFlowLoading must be used within a <StoryboardProvider>')
107
+ })
108
+ })
109
+
110
+ // ── Deprecated aliases ──
111
+
112
+ describe('useSceneData (deprecated alias)', () => {
113
+ it('is the same function as useFlowData', () => {
114
+ expect(useSceneData).toBe(useFlowData)
115
+ })
116
+
117
+ it('returns flow data', () => {
118
+ const { result } = renderHook(() => useSceneData(), {
119
+ wrapper: createWrapper(flowData),
120
+ })
121
+ expect(result.current).toEqual(flowData)
122
+ })
123
+ })
124
+
125
+ describe('useSceneLoading (deprecated alias)', () => {
126
+ it('is the same function as useFlowLoading', () => {
127
+ expect(useSceneLoading).toBe(useFlowLoading)
128
+ })
129
+
130
+ it('returns loading state', () => {
131
+ const { result } = renderHook(() => useSceneLoading(), {
132
+ wrapper: createWrapper(flowData),
133
+ })
134
+ expect(result.current).toBe(false)
107
135
  })
108
136
  })
package/src/index.js CHANGED
@@ -10,16 +10,19 @@ export { default as StoryboardProvider } from './context.jsx'
10
10
  export { StoryboardContext } from './StoryboardContext.js'
11
11
 
12
12
  // Hooks
13
+ export { useFlowData, useFlowLoading } from './hooks/useSceneData.js'
14
+ // Deprecated aliases
13
15
  export { useSceneData, useSceneLoading } from './hooks/useSceneData.js'
14
16
  export { useOverride } from './hooks/useOverride.js'
15
17
  export { useOverride as useSession } from './hooks/useOverride.js' // deprecated alias
16
- export { useScene } from './hooks/useScene.js'
18
+ export { useFlow, useScene } from './hooks/useScene.js'
17
19
  export { useRecord, useRecords } from './hooks/useRecord.js'
18
20
  export { useObject } from './hooks/useObject.js'
19
21
  export { useLocalStorage } from './hooks/useLocalStorage.js'
20
22
  export { useHideMode } from './hooks/useHideMode.js'
21
23
  export { useUndoRedo } from './hooks/useUndoRedo.js'
22
24
  export { useFeatureFlag } from './hooks/useFeatureFlag.js'
25
+ export { useMode } from './hooks/useMode.js'
23
26
 
24
27
  // React Router integration
25
28
  export { installHashPreserver } from './hashPreserver.js'
@@ -27,5 +30,8 @@ export { installHashPreserver } from './hashPreserver.js'
27
30
  // Form context (for design system packages to use)
28
31
  export { FormContext } from './context/FormContext.js'
29
32
 
33
+ // Design mode hook (keep — React apps may still read mode state)
34
+ // ModeSwitch and ToolbarShell UI moved to @dfosco/storyboard-svelte-ui
35
+
30
36
  // Viewfinder dashboard
31
37
  export { default as Viewfinder } from './Viewfinder.jsx'
package/src/test-utils.js CHANGED
@@ -3,7 +3,7 @@ import { StoryboardContext } from './StoryboardContext.js'
3
3
  import { init } from '@dfosco/storyboard-core'
4
4
 
5
5
  // Default test data
6
- export const TEST_SCENES = {
6
+ export const TEST_FLOWS = {
7
7
  default: {
8
8
  user: { name: 'Jane', profile: { bio: 'Dev' } },
9
9
  settings: { theme: 'dark' },
@@ -15,6 +15,9 @@ export const TEST_SCENES = {
15
15
  },
16
16
  }
17
17
 
18
+ /** @deprecated Use TEST_FLOWS */
19
+ export const TEST_SCENES = TEST_FLOWS
20
+
18
21
  export const TEST_OBJECTS = {
19
22
  'jane-doe': { name: 'Jane Doe', role: 'admin' },
20
23
  }
@@ -27,15 +30,15 @@ export const TEST_RECORDS = {
27
30
  }
28
31
 
29
32
  export function seedTestData() {
30
- init({ scenes: TEST_SCENES, objects: TEST_OBJECTS, records: TEST_RECORDS })
33
+ init({ flows: TEST_FLOWS, objects: TEST_OBJECTS, records: TEST_RECORDS })
31
34
  }
32
35
 
33
- // Wrapper that provides StoryboardContext with given scene data
34
- export function createWrapper(sceneData, sceneName = 'default') {
36
+ // Wrapper that provides StoryboardContext with given flow data
37
+ export function createWrapper(flowData, flowName = 'default') {
35
38
  return function Wrapper({ children }) {
36
39
  return createElement(
37
40
  StoryboardContext.Provider,
38
- { value: { data: sceneData, error: null, loading: false, sceneName } },
41
+ { value: { data: flowData, error: null, loading: false, flowName, sceneName: flowName } },
39
42
  children
40
43
  )
41
44
  }