@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/src/hooks/useRecord.js
CHANGED
|
@@ -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.
|
|
@@ -19,15 +20,26 @@ import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
|
|
|
19
20
|
* - Unknown ids create new entries appended to the array.
|
|
20
21
|
*
|
|
21
22
|
* @param {Array} baseRecords - The original record array (will be deep-cloned)
|
|
22
|
-
* @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.
|
|
23
25
|
* @returns {Array} Merged array
|
|
24
26
|
*/
|
|
25
|
-
function applyRecordOverrides(baseRecords,
|
|
27
|
+
function applyRecordOverrides(baseRecords, resolvedName, plainName) {
|
|
26
28
|
const allParams = isHideMode() ? getAllShadows() : getAllParams()
|
|
27
|
-
|
|
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
|
|
28
38
|
|
|
29
39
|
// Collect only the params that target this record
|
|
30
|
-
const overrideKeys = Object.keys(allParams).filter(k =>
|
|
40
|
+
const overrideKeys = Object.keys(allParams).filter(k =>
|
|
41
|
+
k.startsWith(resolvedPrefix) || (plainPrefix && k.startsWith(plainPrefix))
|
|
42
|
+
)
|
|
31
43
|
if (overrideKeys.length === 0) return baseRecords
|
|
32
44
|
|
|
33
45
|
const records = deepClone(baseRecords)
|
|
@@ -36,6 +48,8 @@ function applyRecordOverrides(baseRecords, recordName) {
|
|
|
36
48
|
// key format: record.{name}.{entryId}.{field...}
|
|
37
49
|
const byEntryId = {}
|
|
38
50
|
for (const key of overrideKeys) {
|
|
51
|
+
// Determine which prefix matched to slice correctly
|
|
52
|
+
const prefix = key.startsWith(resolvedPrefix) ? resolvedPrefix : plainPrefix
|
|
39
53
|
const rest = key.slice(prefix.length) // "{entryId}.{field...}"
|
|
40
54
|
const dotIdx = rest.indexOf('.')
|
|
41
55
|
if (dotIdx === -1) continue // no field path — skip
|
|
@@ -91,6 +105,8 @@ function applyRecordOverrides(baseRecords, recordName) {
|
|
|
91
105
|
export function useRecord(recordName, paramName = 'id') {
|
|
92
106
|
const params = useParams()
|
|
93
107
|
const paramValue = params[paramName]
|
|
108
|
+
const context = useContext(StoryboardContext)
|
|
109
|
+
const prototypeName = context?.prototypeName ?? null
|
|
94
110
|
|
|
95
111
|
// Re-render on hash or localStorage changes so overrides are reactive
|
|
96
112
|
const hashString = useSyncExternalStore(subscribeToHash, getHashSnapshot)
|
|
@@ -99,14 +115,15 @@ export function useRecord(recordName, paramName = 'id') {
|
|
|
99
115
|
return useMemo(() => {
|
|
100
116
|
if (!paramValue) return null
|
|
101
117
|
try {
|
|
102
|
-
const
|
|
103
|
-
const
|
|
118
|
+
const resolvedName = resolveRecordName(prototypeName, recordName)
|
|
119
|
+
const base = loadRecord(resolvedName)
|
|
120
|
+
const merged = applyRecordOverrides(base, resolvedName, recordName)
|
|
104
121
|
return merged.find(e => e[paramName] === paramValue) ?? null
|
|
105
122
|
} catch (err) {
|
|
106
123
|
console.error(`[useRecord] ${err.message}`)
|
|
107
124
|
return null
|
|
108
125
|
}
|
|
109
|
-
}, [recordName, paramName, paramValue, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
126
|
+
}, [recordName, paramName, paramValue, prototypeName, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
110
127
|
}
|
|
111
128
|
|
|
112
129
|
/**
|
|
@@ -121,17 +138,21 @@ export function useRecord(recordName, paramName = 'id') {
|
|
|
121
138
|
* const allPosts = useRecords('posts')
|
|
122
139
|
*/
|
|
123
140
|
export function useRecords(recordName) {
|
|
141
|
+
const context = useContext(StoryboardContext)
|
|
142
|
+
const prototypeName = context?.prototypeName ?? null
|
|
143
|
+
|
|
124
144
|
// Re-render on hash or localStorage changes so overrides are reactive
|
|
125
145
|
const hashString = useSyncExternalStore(subscribeToHash, getHashSnapshot)
|
|
126
146
|
const storageString = useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
|
|
127
147
|
|
|
128
148
|
return useMemo(() => {
|
|
129
149
|
try {
|
|
130
|
-
const
|
|
131
|
-
|
|
150
|
+
const resolvedName = resolveRecordName(prototypeName, recordName)
|
|
151
|
+
const base = loadRecord(resolvedName)
|
|
152
|
+
return applyRecordOverrides(base, resolvedName, recordName)
|
|
132
153
|
} catch (err) {
|
|
133
154
|
console.error(`[useRecords] ${err.message}`)
|
|
134
155
|
return []
|
|
135
156
|
}
|
|
136
|
-
}, [recordName, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
157
|
+
}, [recordName, prototypeName, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
137
158
|
}
|
|
@@ -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
|
@@ -2,27 +2,37 @@ import { useContext, useCallback } from 'react'
|
|
|
2
2
|
import { StoryboardContext } from '../StoryboardContext.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Read the current
|
|
5
|
+
* Read the current flow name and programmatically switch flows.
|
|
6
6
|
*
|
|
7
|
-
* @returns {{
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
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 ?flow= param
|
|
10
10
|
*/
|
|
11
|
-
export function
|
|
11
|
+
export function useFlow() {
|
|
12
12
|
const context = useContext(StoryboardContext)
|
|
13
13
|
if (context === null) {
|
|
14
|
-
throw new Error('
|
|
14
|
+
throw new Error('useFlow must be used within a <StoryboardProvider>')
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
const
|
|
17
|
+
const switchFlow = useCallback((name) => {
|
|
18
18
|
const url = new URL(window.location.href)
|
|
19
|
-
url.searchParams.
|
|
20
|
-
|
|
19
|
+
url.searchParams.delete('scene')
|
|
20
|
+
url.searchParams.set('flow', name)
|
|
21
|
+
// Preserve hash params across flow switches
|
|
21
22
|
window.location.href = url.toString()
|
|
22
23
|
}, [])
|
|
23
24
|
|
|
24
25
|
return {
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
flowName: context.flowName,
|
|
27
|
+
switchFlow,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @deprecated Use useFlow() */
|
|
32
|
+
export function useScene() {
|
|
33
|
+
const { flowName, switchFlow } = useFlow()
|
|
34
|
+
return {
|
|
35
|
+
sceneName: flowName,
|
|
36
|
+
switchScene: switchFlow,
|
|
27
37
|
}
|
|
28
38
|
}
|
|
@@ -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,
|
|
4
|
+
import { seedTestData, createWrapper, TEST_FLOWS } from '../test-utils.js'
|
|
4
5
|
|
|
5
|
-
const
|
|
6
|
+
const flowData = TEST_FLOWS.default
|
|
6
7
|
|
|
7
8
|
beforeEach(() => {
|
|
8
9
|
seedTestData()
|
|
9
10
|
})
|
|
10
11
|
|
|
11
|
-
describe('
|
|
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(
|
|
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
|
|
53
|
+
it('sceneName matches the flow name from context', () => {
|
|
21
54
|
const { result } = renderHook(() => useScene(), {
|
|
22
|
-
wrapper: createWrapper(
|
|
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(
|
|
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
|
|
11
|
-
* Hash params override
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
|
23
|
+
export function useFlowData(path) {
|
|
24
24
|
const context = useContext(StoryboardContext)
|
|
25
25
|
|
|
26
26
|
if (context === null) {
|
|
27
|
-
throw new Error('
|
|
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(`[
|
|
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
|
|
90
|
+
* Returns true while flow data is still loading.
|
|
88
91
|
*/
|
|
89
|
-
export function
|
|
92
|
+
export function useFlowLoading() {
|
|
90
93
|
const context = useContext(StoryboardContext)
|
|
91
94
|
|
|
92
95
|
if (context === null) {
|
|
93
|
-
throw new Error('
|
|
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,
|
|
2
|
+
import { useFlowData, useFlowLoading, useSceneData, useSceneLoading } from './useSceneData.js'
|
|
3
|
+
import { seedTestData, createWrapper, TEST_FLOWS } from '../test-utils.js'
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const flowData = TEST_FLOWS.default
|
|
6
6
|
|
|
7
7
|
beforeEach(() => {
|
|
8
8
|
seedTestData()
|
|
9
9
|
})
|
|
10
10
|
|
|
11
|
-
describe('
|
|
12
|
-
it('returns entire
|
|
13
|
-
const { result } = renderHook(() =>
|
|
14
|
-
wrapper: createWrapper(
|
|
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(
|
|
16
|
+
expect(result.current).toEqual(flowData)
|
|
17
17
|
})
|
|
18
18
|
|
|
19
19
|
it('returns nested value by dot-notation path', () => {
|
|
20
|
-
const { result } = renderHook(() =>
|
|
21
|
-
wrapper: createWrapper(
|
|
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(() =>
|
|
28
|
-
wrapper: createWrapper(
|
|
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(() =>
|
|
35
|
-
wrapper: createWrapper(
|
|
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(() =>
|
|
45
|
-
wrapper: createWrapper(
|
|
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(() =>
|
|
53
|
-
wrapper: createWrapper(
|
|
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(() =>
|
|
65
|
-
}).toThrow('
|
|
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(() =>
|
|
71
|
-
wrapper: createWrapper(
|
|
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(() =>
|
|
79
|
-
wrapper: createWrapper(
|
|
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
|
|
85
|
+
it('returns full flow with all overrides applied when no path', () => {
|
|
86
86
|
window.location.hash = '#user.name=Alice'
|
|
87
|
-
const { result } = renderHook(() =>
|
|
88
|
-
wrapper: createWrapper(
|
|
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('
|
|
95
|
+
describe('useFlowLoading', () => {
|
|
96
96
|
it('returns false when not loading', () => {
|
|
97
|
-
const { result } = renderHook(() =>
|
|
98
|
-
wrapper: createWrapper(
|
|
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(() =>
|
|
106
|
-
}).toThrow('
|
|
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,10 +10,12 @@ 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'
|
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
|
|
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({
|
|
33
|
+
init({ flows: TEST_FLOWS, objects: TEST_OBJECTS, records: TEST_RECORDS })
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
// Wrapper that provides StoryboardContext with given
|
|
34
|
-
export function createWrapper(
|
|
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:
|
|
41
|
+
{ value: { data: flowData, error: null, loading: false, flowName, sceneName: flowName } },
|
|
39
42
|
children
|
|
40
43
|
)
|
|
41
44
|
}
|