@dfosco/storyboard-react 1.20.0 → 1.22.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 +1 -1
- package/src/hooks/useObject.js +83 -0
- package/src/hooks/useObject.test.js +74 -0
- package/src/hooks/useOverride.js +10 -6
- package/src/hooks/useOverride.test.js +9 -4
- package/src/index.js +1 -1
- package/src/vite/data-plugin.js +5 -1
- package/src/hooks/useRecordOverride.js +0 -22
- package/src/hooks/useRecordOverride.test.js +0 -52
package/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useMemo, useSyncExternalStore } from 'react'
|
|
2
|
+
import { loadObject } from '@dfosco/storyboard-core'
|
|
3
|
+
import { getByPath, deepClone, setByPath } from '@dfosco/storyboard-core'
|
|
4
|
+
import { getParam, getAllParams } from '@dfosco/storyboard-core'
|
|
5
|
+
import { isHideMode, getShadow, getAllShadows } from '@dfosco/storyboard-core'
|
|
6
|
+
import { subscribeToHash, getHashSnapshot } from '@dfosco/storyboard-core'
|
|
7
|
+
import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Load an object data file directly by name, without going through a scene.
|
|
11
|
+
* Supports dot-notation path access and URL hash overrides.
|
|
12
|
+
*
|
|
13
|
+
* Hash override convention: object.{objectName}.{field}=value
|
|
14
|
+
*
|
|
15
|
+
* @param {string} objectName - Name of the object file (e.g., "jane-doe")
|
|
16
|
+
* @param {string} [path] - Optional dot-notation path (e.g., "profile.name")
|
|
17
|
+
* @returns {*} The resolved value, or undefined if loading fails
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* const user = useObject('jane-doe')
|
|
21
|
+
* const name = useObject('jane-doe', 'profile.name')
|
|
22
|
+
*
|
|
23
|
+
* // Override via URL hash: #object.jane-doe.name=Alice
|
|
24
|
+
*/
|
|
25
|
+
export function useObject(objectName, path) {
|
|
26
|
+
const hashString = useSyncExternalStore(subscribeToHash, getHashSnapshot)
|
|
27
|
+
const storageString = useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
|
|
28
|
+
|
|
29
|
+
return useMemo(() => {
|
|
30
|
+
let data
|
|
31
|
+
try {
|
|
32
|
+
data = loadObject(objectName)
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error(`[useObject] ${err.message}`)
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const hidden = isHideMode()
|
|
39
|
+
const readParam = hidden ? getShadow : getParam
|
|
40
|
+
const readAllParams = hidden ? getAllShadows : getAllParams
|
|
41
|
+
|
|
42
|
+
// Apply overrides scoped to this object
|
|
43
|
+
const prefix = `object.${objectName}.`
|
|
44
|
+
const allParams = readAllParams()
|
|
45
|
+
const overrideKeys = Object.keys(allParams).filter(k => k.startsWith(prefix))
|
|
46
|
+
|
|
47
|
+
if (overrideKeys.length > 0) {
|
|
48
|
+
data = deepClone(data)
|
|
49
|
+
for (const key of overrideKeys) {
|
|
50
|
+
const fieldPath = key.slice(prefix.length)
|
|
51
|
+
setByPath(data, fieldPath, allParams[key])
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!path) return data
|
|
56
|
+
|
|
57
|
+
// Exact match for this sub-path override
|
|
58
|
+
const exactKey = `${prefix}${path}`
|
|
59
|
+
const exact = readParam(exactKey)
|
|
60
|
+
if (exact !== null) return exact
|
|
61
|
+
|
|
62
|
+
// Child overrides under the sub-path
|
|
63
|
+
const subPrefix = exactKey + '.'
|
|
64
|
+
const childKeys = overrideKeys.filter(k => k.startsWith(subPrefix))
|
|
65
|
+
const baseValue = getByPath(data, path)
|
|
66
|
+
|
|
67
|
+
if (childKeys.length > 0 && baseValue !== undefined) {
|
|
68
|
+
const merged = deepClone(baseValue)
|
|
69
|
+
for (const key of childKeys) {
|
|
70
|
+
const relativePath = key.slice(subPrefix.length)
|
|
71
|
+
setByPath(merged, relativePath, allParams[key])
|
|
72
|
+
}
|
|
73
|
+
return merged
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (baseValue === undefined) {
|
|
77
|
+
console.warn(`[useObject] Path "${path}" not found in object "${objectName}".`)
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return baseValue
|
|
82
|
+
}, [objectName, path, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
83
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react'
|
|
2
|
+
import { seedTestData, TEST_OBJECTS } from '../../test-utils.js'
|
|
3
|
+
import { activateHideMode, setShadow } from '@dfosco/storyboard-core'
|
|
4
|
+
import { useObject } from './useObject.js'
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
seedTestData()
|
|
8
|
+
window.location.hash = ''
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe('useObject', () => {
|
|
12
|
+
it('loads an object by name', () => {
|
|
13
|
+
const { result } = renderHook(() => useObject('jane-doe'))
|
|
14
|
+
expect(result.current).toEqual(TEST_OBJECTS['jane-doe'])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('returns undefined for missing object', () => {
|
|
18
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
19
|
+
const { result } = renderHook(() => useObject('nonexistent'))
|
|
20
|
+
expect(result.current).toBeUndefined()
|
|
21
|
+
console.error.mockRestore()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('resolves dot-notation path', () => {
|
|
25
|
+
const { result } = renderHook(() => useObject('jane-doe', 'name'))
|
|
26
|
+
expect(result.current).toBe('Jane Doe')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns undefined for missing path', () => {
|
|
30
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
31
|
+
const { result } = renderHook(() => useObject('jane-doe', 'missing.path'))
|
|
32
|
+
expect(result.current).toBeUndefined()
|
|
33
|
+
console.warn.mockRestore()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('applies hash overrides to full object', () => {
|
|
37
|
+
window.location.hash = 'object.jane-doe.name=Alice'
|
|
38
|
+
const { result } = renderHook(() => useObject('jane-doe'))
|
|
39
|
+
expect(result.current.name).toBe('Alice')
|
|
40
|
+
expect(result.current.role).toBe('admin')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('applies hash overrides when accessing by path', () => {
|
|
44
|
+
window.location.hash = 'object.jane-doe.name=Alice'
|
|
45
|
+
const { result } = renderHook(() => useObject('jane-doe', 'name'))
|
|
46
|
+
expect(result.current).toBe('Alice')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns deep clone (mutations do not affect source data)', () => {
|
|
50
|
+
const { result: r1 } = renderHook(() => useObject('jane-doe'))
|
|
51
|
+
r1.current.name = 'Mutated'
|
|
52
|
+
// A fresh hook call should return original data, not the mutated reference
|
|
53
|
+
const { result: r2 } = renderHook(() => useObject('jane-doe'))
|
|
54
|
+
expect(r2.current.name).toBe('Jane Doe')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('useObject (hide mode)', () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
act(() => { activateHideMode() })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('reads overrides from localStorage shadow in hide mode', () => {
|
|
64
|
+
act(() => { setShadow('object.jane-doe.name', 'Shadow Jane') })
|
|
65
|
+
const { result } = renderHook(() => useObject('jane-doe'))
|
|
66
|
+
expect(result.current.name).toBe('Shadow Jane')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('reads path-specific overrides from shadow in hide mode', () => {
|
|
70
|
+
act(() => { setShadow('object.jane-doe.role', 'superadmin') })
|
|
71
|
+
const { result } = renderHook(() => useObject('jane-doe', 'role'))
|
|
72
|
+
expect(result.current).toBe('superadmin')
|
|
73
|
+
})
|
|
74
|
+
})
|
package/src/hooks/useOverride.js
CHANGED
|
@@ -7,7 +7,7 @@ import { isHideMode, getShadow, setShadow, removeShadow } from '@dfosco/storyboa
|
|
|
7
7
|
import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Read/write overrides on top of scene data.
|
|
10
|
+
* Read/write overrides on top of scene data or object data.
|
|
11
11
|
*
|
|
12
12
|
* **Normal mode:**
|
|
13
13
|
* Read priority: URL hash param → Scene JSON value → undefined
|
|
@@ -20,19 +20,23 @@ import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
|
|
|
20
20
|
* Every write also mirrors to localStorage shadow keys, so hide mode
|
|
21
21
|
* can hot-swap without data loss.
|
|
22
22
|
*
|
|
23
|
+
* Works with any override namespace — scene paths (e.g. 'settings.theme'),
|
|
24
|
+
* object paths (e.g. 'object.jane-doe.name'), or record paths
|
|
25
|
+
* (e.g. 'record.posts.post-1.title').
|
|
26
|
+
*
|
|
27
|
+
* When used outside a StoryboardProvider (e.g. for object overrides),
|
|
28
|
+
* the scene fallback is skipped and value resolves to override ?? undefined.
|
|
29
|
+
*
|
|
23
30
|
* @param {string} path - Dot-notation key (e.g. 'settings.theme')
|
|
24
31
|
* @returns {[any, function, function]}
|
|
25
|
-
* [0] current value (override ?? scene default)
|
|
32
|
+
* [0] current value (override ?? scene default ?? undefined)
|
|
26
33
|
* [1] setValue(newValue) – write an override
|
|
27
34
|
* [2] clearValue() – remove the override, reverting to scene default
|
|
28
35
|
*/
|
|
29
36
|
export function useOverride(path) {
|
|
30
37
|
const context = useContext(StoryboardContext)
|
|
31
|
-
if (context === null) {
|
|
32
|
-
throw new Error('useOverride must be used within a <StoryboardProvider>')
|
|
33
|
-
}
|
|
34
38
|
|
|
35
|
-
const
|
|
39
|
+
const data = context?.data
|
|
36
40
|
const hidden = isHideMode()
|
|
37
41
|
|
|
38
42
|
// Scene default for this path (fallback when no override exists)
|
|
@@ -58,9 +58,14 @@ describe('useOverride', () => {
|
|
|
58
58
|
expect(window.location.hash).not.toContain('settings.theme')
|
|
59
59
|
})
|
|
60
60
|
|
|
61
|
-
it('
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
it('works without StoryboardProvider for object overrides', () => {
|
|
62
|
+
window.location.hash = '#object.jane-doe.name=Alice'
|
|
63
|
+
const { result } = renderHook(() => useOverride('object.jane-doe.name'))
|
|
64
|
+
expect(result.current[0]).toBe('Alice')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns undefined without provider when no override exists', () => {
|
|
68
|
+
const { result } = renderHook(() => useOverride('object.jane-doe.name'))
|
|
69
|
+
expect(result.current[0]).toBeUndefined()
|
|
65
70
|
})
|
|
66
71
|
})
|
package/src/index.js
CHANGED
|
@@ -15,7 +15,7 @@ export { useOverride } from './hooks/useOverride.js'
|
|
|
15
15
|
export { useOverride as useSession } from './hooks/useOverride.js' // deprecated alias
|
|
16
16
|
export { useScene } from './hooks/useScene.js'
|
|
17
17
|
export { useRecord, useRecords } from './hooks/useRecord.js'
|
|
18
|
-
export {
|
|
18
|
+
export { useObject } from './hooks/useObject.js'
|
|
19
19
|
export { useLocalStorage } from './hooks/useLocalStorage.js'
|
|
20
20
|
export { useHideMode } from './hooks/useHideMode.js'
|
|
21
21
|
export { useUndoRedo } from './hooks/useUndoRedo.js'
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -67,7 +67,11 @@ function readConfig(root) {
|
|
|
67
67
|
const configPath = path.resolve(root, 'storyboard.config.json')
|
|
68
68
|
try {
|
|
69
69
|
const raw = fs.readFileSync(configPath, 'utf-8')
|
|
70
|
-
|
|
70
|
+
const errors = []
|
|
71
|
+
const config = parseJsonc(raw, errors)
|
|
72
|
+
// Treat malformed JSON (e.g. mid-edit partial saves) as missing config
|
|
73
|
+
if (errors.length > 0) return { config: null, configPath }
|
|
74
|
+
return { config, configPath }
|
|
71
75
|
} catch {
|
|
72
76
|
return { config: null, configPath }
|
|
73
77
|
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { useOverride } from './useOverride.js'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Read/write hash-param overrides for a specific record entry field.
|
|
5
|
-
*
|
|
6
|
-
* Builds the full override path as `record.{recordName}.{entryId}.{field}`
|
|
7
|
-
* and delegates to `useOverride`.
|
|
8
|
-
*
|
|
9
|
-
* @param {string} recordName - Record collection name (e.g. "posts")
|
|
10
|
-
* @param {string} entryId - The id of the record entry to override
|
|
11
|
-
* @param {string} field - Dot-notation field path within the entry (e.g. "title" or "author.name")
|
|
12
|
-
* @returns {[any, function, function]}
|
|
13
|
-
* [0] current value (override ?? record default)
|
|
14
|
-
* [1] setValue(newValue) – write an override to the URL hash
|
|
15
|
-
* [2] clearValue() – remove the override
|
|
16
|
-
*
|
|
17
|
-
* @example
|
|
18
|
-
* const [title, setTitle, clearTitle] = useRecordOverride('posts', 'welcome-to-storyboard', 'title')
|
|
19
|
-
*/
|
|
20
|
-
export function useRecordOverride(recordName, entryId, field) {
|
|
21
|
-
return useOverride(`record.${recordName}.${entryId}.${field}`)
|
|
22
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { renderHook, act } from '@testing-library/react'
|
|
2
|
-
import { seedTestData, createWrapper, TEST_SCENES } from '../../test-utils.js'
|
|
3
|
-
import { useRecordOverride } from './useRecordOverride.js'
|
|
4
|
-
|
|
5
|
-
beforeEach(() => {
|
|
6
|
-
seedTestData()
|
|
7
|
-
})
|
|
8
|
-
|
|
9
|
-
const wrapper = createWrapper(TEST_SCENES.default)
|
|
10
|
-
|
|
11
|
-
describe('useRecordOverride', () => {
|
|
12
|
-
it('returns [value, setValue, clearValue]', () => {
|
|
13
|
-
const { result } = renderHook(
|
|
14
|
-
() => useRecordOverride('posts', 'post-1', 'title'),
|
|
15
|
-
{ wrapper },
|
|
16
|
-
)
|
|
17
|
-
expect(result.current).toHaveLength(3)
|
|
18
|
-
expect(typeof result.current[1]).toBe('function')
|
|
19
|
-
expect(typeof result.current[2]).toBe('function')
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
it('builds correct override path (record.posts.post-1.title)', () => {
|
|
23
|
-
const { result } = renderHook(
|
|
24
|
-
() => useRecordOverride('posts', 'post-1', 'title'),
|
|
25
|
-
{ wrapper },
|
|
26
|
-
)
|
|
27
|
-
// scene data has record.posts.post-1.title = 'Original Title'
|
|
28
|
-
expect(result.current[0]).toBe('Original Title')
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
it('returns undefined when path does not exist in scene data', () => {
|
|
32
|
-
const { result } = renderHook(
|
|
33
|
-
() => useRecordOverride('posts', 'post-99', 'title'),
|
|
34
|
-
{ wrapper },
|
|
35
|
-
)
|
|
36
|
-
expect(result.current[0]).toBeUndefined()
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it('setValue writes to hash at the correct path', () => {
|
|
40
|
-
const { result } = renderHook(
|
|
41
|
-
() => useRecordOverride('posts', 'post-1', 'title'),
|
|
42
|
-
{ wrapper },
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
act(() => {
|
|
46
|
-
result.current[1]('New Title')
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
const params = new URLSearchParams(window.location.hash.replace(/^#/, ''))
|
|
50
|
-
expect(params.get('record.posts.post-1.title')).toBe('New Title')
|
|
51
|
-
})
|
|
52
|
-
})
|