@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "1.20.0",
3
+ "version": "1.22.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@dfosco/storyboard-core": "*",
@@ -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
+ })
@@ -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 { data } = context
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('throws when used outside StoryboardProvider', () => {
62
- expect(() => {
63
- renderHook(() => useOverride('settings.theme'))
64
- }).toThrow('useOverride must be used within a <StoryboardProvider>')
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 { useRecordOverride } from './hooks/useRecordOverride.js'
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'
@@ -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
- return { config: parseJsonc(raw), configPath }
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
- })