@dfosco/storyboard-react 1.1.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.
@@ -0,0 +1,108 @@
1
+ import { renderHook } from '@testing-library/react'
2
+ import { useSceneData, useSceneLoading } from './useSceneData.js'
3
+ import { seedTestData, createWrapper, TEST_SCENES } from '../test-utils.js'
4
+
5
+ const sceneData = TEST_SCENES.default
6
+
7
+ beforeEach(() => {
8
+ seedTestData()
9
+ })
10
+
11
+ describe('useSceneData', () => {
12
+ it('returns entire scene object when no path given', () => {
13
+ const { result } = renderHook(() => useSceneData(), {
14
+ wrapper: createWrapper(sceneData),
15
+ })
16
+ expect(result.current).toEqual(sceneData)
17
+ })
18
+
19
+ it('returns nested value by dot-notation path', () => {
20
+ const { result } = renderHook(() => useSceneData('user.name'), {
21
+ wrapper: createWrapper(sceneData),
22
+ })
23
+ expect(result.current).toBe('Jane')
24
+ })
25
+
26
+ it('returns deep nested value', () => {
27
+ const { result } = renderHook(() => useSceneData('user.profile.bio'), {
28
+ wrapper: createWrapper(sceneData),
29
+ })
30
+ expect(result.current).toBe('Dev')
31
+ })
32
+
33
+ it('returns array by path', () => {
34
+ const { result } = renderHook(() => useSceneData('projects'), {
35
+ wrapper: createWrapper(sceneData),
36
+ })
37
+ expect(result.current).toEqual([
38
+ { id: 1, name: 'alpha' },
39
+ { id: 2, name: 'beta' },
40
+ ])
41
+ })
42
+
43
+ it('returns array element by index path', () => {
44
+ const { result } = renderHook(() => useSceneData('projects.0'), {
45
+ wrapper: createWrapper(sceneData),
46
+ })
47
+ expect(result.current).toEqual({ id: 1, name: 'alpha' })
48
+ })
49
+
50
+ it('returns empty object and warns for missing path', () => {
51
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
52
+ const { result } = renderHook(() => useSceneData('nonexistent.path'), {
53
+ wrapper: createWrapper(sceneData),
54
+ })
55
+ expect(result.current).toEqual({})
56
+ expect(spy).toHaveBeenCalledWith(
57
+ expect.stringContaining('nonexistent.path')
58
+ )
59
+ spy.mockRestore()
60
+ })
61
+
62
+ it('throws when used outside StoryboardProvider', () => {
63
+ expect(() => {
64
+ renderHook(() => useSceneData())
65
+ }).toThrow('useSceneData must be used within a <StoryboardProvider>')
66
+ })
67
+
68
+ it('returns hash override value when param matches path', () => {
69
+ window.location.hash = '#user.name=Alice'
70
+ const { result } = renderHook(() => useSceneData('user.name'), {
71
+ wrapper: createWrapper(sceneData),
72
+ })
73
+ expect(result.current).toBe('Alice')
74
+ })
75
+
76
+ it('applies child overrides to arrays', () => {
77
+ window.location.hash = '#projects.0.name=gamma'
78
+ const { result } = renderHook(() => useSceneData('projects'), {
79
+ wrapper: createWrapper(sceneData),
80
+ })
81
+ expect(result.current[0].name).toBe('gamma')
82
+ expect(result.current[1].name).toBe('beta')
83
+ })
84
+
85
+ it('returns full scene with all overrides applied when no path', () => {
86
+ window.location.hash = '#user.name=Alice'
87
+ const { result } = renderHook(() => useSceneData(), {
88
+ wrapper: createWrapper(sceneData),
89
+ })
90
+ expect(result.current.user.name).toBe('Alice')
91
+ expect(result.current.settings.theme).toBe('dark')
92
+ })
93
+ })
94
+
95
+ describe('useSceneLoading', () => {
96
+ it('returns false when not loading', () => {
97
+ const { result } = renderHook(() => useSceneLoading(), {
98
+ wrapper: createWrapper(sceneData),
99
+ })
100
+ expect(result.current).toBe(false)
101
+ })
102
+
103
+ it('throws when used outside StoryboardProvider', () => {
104
+ expect(() => {
105
+ renderHook(() => useSceneLoading())
106
+ }).toThrow('useSceneLoading must be used within a <StoryboardProvider>')
107
+ })
108
+ })
@@ -0,0 +1,4 @@
1
+ /**
2
+ * @deprecated Use `useOverride` instead. This re-export exists for backwards compatibility.
3
+ */
4
+ export { useOverride as useSession } from './useOverride.js'
@@ -0,0 +1,8 @@
1
+ import { useSession } from './useSession.js'
2
+ import { useOverride } from './useOverride.js'
3
+
4
+ describe('useSession', () => {
5
+ it('is the same function as useOverride', () => {
6
+ expect(useSession).toBe(useOverride)
7
+ })
8
+ })
@@ -0,0 +1,28 @@
1
+ import { useCallback, useSyncExternalStore } from 'react'
2
+ import { undo, redo, canUndo, canRedo } from '@dfosco/storyboard-core'
3
+ import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
4
+ import { subscribeToHash, getHashSnapshot } from '@dfosco/storyboard-core'
5
+
6
+ /**
7
+ * Undo/redo controls for override history.
8
+ *
9
+ * Every override write (via useOverride) pushes a snapshot to the history
10
+ * stack. This hook exposes navigation through that stack.
11
+ *
12
+ * @returns {{ undo: function, redo: function, canUndo: boolean, canRedo: boolean }}
13
+ */
14
+ export function useUndoRedo() {
15
+ // Re-render on storage or hash changes
16
+ useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
17
+ useSyncExternalStore(subscribeToHash, getHashSnapshot)
18
+
19
+ const handleUndo = useCallback(() => undo(), [])
20
+ const handleRedo = useCallback(() => redo(), [])
21
+
22
+ return {
23
+ undo: handleUndo,
24
+ redo: handleRedo,
25
+ canUndo: canUndo(),
26
+ canRedo: canRedo(),
27
+ }
28
+ }
@@ -0,0 +1,64 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { seedTestData } from '../../test-utils.js'
3
+ import { pushSnapshot } from '@dfosco/storyboard-core'
4
+ import { useUndoRedo } from './useUndoRedo.js'
5
+
6
+ beforeEach(() => {
7
+ seedTestData()
8
+ })
9
+
10
+ describe('useUndoRedo', () => {
11
+ it('returns { undo, redo, canUndo, canRedo }', () => {
12
+ const { result } = renderHook(() => useUndoRedo())
13
+ expect(typeof result.current.undo).toBe('function')
14
+ expect(typeof result.current.redo).toBe('function')
15
+ expect(typeof result.current.canUndo).toBe('boolean')
16
+ expect(typeof result.current.canRedo).toBe('boolean')
17
+ })
18
+
19
+ it('canUndo and canRedo are false initially', () => {
20
+ const { result } = renderHook(() => useUndoRedo())
21
+ expect(result.current.canUndo).toBe(false)
22
+ expect(result.current.canRedo).toBe(false)
23
+ })
24
+
25
+ it('after pushing snapshots and undoing, canUndo/canRedo reflect state', () => {
26
+ // Pre-populate history with 3 entries so we can undo twice
27
+ pushSnapshot('a=1', '/')
28
+ pushSnapshot('b=2', '/')
29
+ pushSnapshot('c=3', '/')
30
+
31
+ const { result, rerender } = renderHook(() => useUndoRedo())
32
+
33
+ // At index 2 (last entry), can undo but not redo
34
+ expect(result.current.canUndo).toBe(true)
35
+ expect(result.current.canRedo).toBe(false)
36
+
37
+ act(() => {
38
+ result.current.undo()
39
+ })
40
+
41
+ rerender()
42
+ // At index 1, can undo and redo
43
+ expect(result.current.canUndo).toBe(true)
44
+ expect(result.current.canRedo).toBe(true)
45
+
46
+ act(() => {
47
+ result.current.undo()
48
+ })
49
+
50
+ rerender()
51
+ // At index 0, cannot undo but can redo
52
+ expect(result.current.canUndo).toBe(false)
53
+ expect(result.current.canRedo).toBe(true)
54
+
55
+ act(() => {
56
+ result.current.redo()
57
+ })
58
+
59
+ rerender()
60
+ // At index 1 again, can undo and redo
61
+ expect(result.current.canUndo).toBe(true)
62
+ expect(result.current.canRedo).toBe(true)
63
+ })
64
+ })
package/src/index.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @dfosco/storyboard-react — React framework binding for Storyboard.
3
+ *
4
+ * Provides hooks, context, and provider for React apps.
5
+ * Design-system-agnostic — no Primer, Reshaped, or other UI library deps.
6
+ */
7
+
8
+ // Context & Provider
9
+ export { default as StoryboardProvider } from './context.jsx'
10
+ export { StoryboardContext } from './StoryboardContext.js'
11
+
12
+ // Hooks
13
+ export { useSceneData, useSceneLoading } from './hooks/useSceneData.js'
14
+ export { useOverride } from './hooks/useOverride.js'
15
+ export { useOverride as useSession } from './hooks/useOverride.js' // deprecated alias
16
+ export { useScene } from './hooks/useScene.js'
17
+ export { useRecord, useRecords } from './hooks/useRecord.js'
18
+ export { useRecordOverride } from './hooks/useRecordOverride.js'
19
+ export { useLocalStorage } from './hooks/useLocalStorage.js'
20
+ export { useHideMode } from './hooks/useHideMode.js'
21
+ export { useUndoRedo } from './hooks/useUndoRedo.js'
22
+
23
+ // React Router integration
24
+ export { installHashPreserver } from './hashPreserver.js'
25
+
26
+ // Form context (for design system packages to use)
27
+ export { FormContext } from './context/FormContext.js'
@@ -0,0 +1,42 @@
1
+ import { createElement } from 'react'
2
+ import { StoryboardContext } from './StoryboardContext.js'
3
+ import { init } from '@dfosco/storyboard-core'
4
+
5
+ // Default test data
6
+ export const TEST_SCENES = {
7
+ default: {
8
+ user: { name: 'Jane', profile: { bio: 'Dev' } },
9
+ settings: { theme: 'dark' },
10
+ projects: [{ id: 1, name: 'alpha' }, { id: 2, name: 'beta' }],
11
+ },
12
+ other: {
13
+ user: { name: 'Bob' },
14
+ settings: { theme: 'light' },
15
+ },
16
+ }
17
+
18
+ export const TEST_OBJECTS = {
19
+ 'jane-doe': { name: 'Jane Doe', role: 'admin' },
20
+ }
21
+
22
+ export const TEST_RECORDS = {
23
+ posts: [
24
+ { id: 'post-1', title: 'First Post', author: 'Jane' },
25
+ { id: 'post-2', title: 'Second Post', author: 'Bob' },
26
+ ],
27
+ }
28
+
29
+ export function seedTestData() {
30
+ init({ scenes: TEST_SCENES, objects: TEST_OBJECTS, records: TEST_RECORDS })
31
+ }
32
+
33
+ // Wrapper that provides StoryboardContext with given scene data
34
+ export function createWrapper(sceneData, sceneName = 'default') {
35
+ return function Wrapper({ children }) {
36
+ return createElement(
37
+ StoryboardContext.Provider,
38
+ { value: { data: sceneData, error: null, loading: false, sceneName } },
39
+ children
40
+ )
41
+ }
42
+ }
@@ -0,0 +1,151 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { globSync } from 'glob'
4
+ import { parse as parseJsonc } from 'jsonc-parser'
5
+
6
+ const VIRTUAL_MODULE_ID = 'virtual:storyboard-data-index'
7
+ const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
8
+
9
+ const SUFFIXES = ['scene', 'object', 'record']
10
+ const GLOB_PATTERN = '**/*.{scene,object,record}.{json,jsonc}'
11
+
12
+ /**
13
+ * Extract the data name and type suffix from a file path.
14
+ * e.g. "src/data/default.scene.json" → { name: "default", suffix: "scene" }
15
+ * "anywhere/posts.record.jsonc" → { name: "posts", suffix: "record" }
16
+ */
17
+ function parseDataFile(filePath) {
18
+ const base = path.basename(filePath)
19
+ const match = base.match(/^(.+)\.(scene|object|record)\.(jsonc?)$/)
20
+ if (!match) return null
21
+ return { name: match[1], suffix: match[2], ext: match[3] }
22
+ }
23
+
24
+ /**
25
+ * Scan the repo for all data files, validate uniqueness, return the index.
26
+ */
27
+ function buildIndex(root) {
28
+ const ignore = ['node_modules/**', 'dist/**', '.git/**']
29
+ const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
30
+
31
+ const index = { scene: {}, object: {}, record: {} }
32
+ const seen = {} // "name.suffix" → absolute path (for duplicate detection)
33
+
34
+ for (const relPath of files) {
35
+ const parsed = parseDataFile(relPath)
36
+ if (!parsed) continue
37
+
38
+ const key = `${parsed.name}.${parsed.suffix}`
39
+ const absPath = path.resolve(root, relPath)
40
+
41
+ if (seen[key]) {
42
+ throw new Error(
43
+ `[storyboard-data] Duplicate data file: "${key}.json"\n` +
44
+ ` Found at: ${seen[key]}\n` +
45
+ ` And at: ${absPath}\n` +
46
+ ` Every data file name+suffix must be unique across the repo.`
47
+ )
48
+ }
49
+
50
+ seen[key] = absPath
51
+ index[parsed.suffix][parsed.name] = absPath
52
+ }
53
+
54
+ return index
55
+ }
56
+
57
+ /**
58
+ * Generate the virtual module source code.
59
+ * Reads each data file, parses JSONC at build time, and emits pre-parsed
60
+ * JavaScript objects — no runtime parsing needed.
61
+ */
62
+ function generateModule(index) {
63
+ const declarations = []
64
+ const entries = { scene: [], object: [], record: [] }
65
+ let i = 0
66
+
67
+ for (const suffix of SUFFIXES) {
68
+ for (const [name, absPath] of Object.entries(index[suffix])) {
69
+ const varName = `_d${i++}`
70
+ const raw = fs.readFileSync(absPath, 'utf-8')
71
+ const parsed = parseJsonc(raw)
72
+ declarations.push(`const ${varName} = ${JSON.stringify(parsed)}`)
73
+ entries[suffix].push(` ${JSON.stringify(name)}: ${varName}`)
74
+ }
75
+ }
76
+
77
+ return [
78
+ `import { init } from '@dfosco/storyboard-core'`,
79
+ '',
80
+ declarations.join('\n'),
81
+ '',
82
+ `const scenes = {\n${entries.scene.join(',\n')}\n}`,
83
+ `const objects = {\n${entries.object.join(',\n')}\n}`,
84
+ `const records = {\n${entries.record.join(',\n')}\n}`,
85
+ '',
86
+ `init({ scenes, objects, records })`,
87
+ '',
88
+ `export { scenes, objects, records }`,
89
+ `export const index = { scenes, objects, records }`,
90
+ `export default index`,
91
+ ].join('\n')
92
+ }
93
+
94
+ /**
95
+ * Vite plugin for storyboard data discovery.
96
+ *
97
+ * - Scans the repo for *.scene.json, *.object.json, *.record.json
98
+ * - Validates no two files share the same name+suffix (hard build error)
99
+ * - Generates a virtual module `virtual:storyboard-data-index`
100
+ * - Watches for file additions/removals in dev mode
101
+ */
102
+ export default function storyboardDataPlugin() {
103
+ let root = ''
104
+ let index = null
105
+
106
+ return {
107
+ name: 'storyboard-data',
108
+ enforce: 'pre',
109
+
110
+ configResolved(config) {
111
+ root = config.root
112
+ },
113
+
114
+ resolveId(id) {
115
+ if (id === VIRTUAL_MODULE_ID) return RESOLVED_ID
116
+ },
117
+
118
+ load(id) {
119
+ if (id !== RESOLVED_ID) return null
120
+ if (!index) index = buildIndex(root)
121
+ return generateModule(index)
122
+ },
123
+
124
+ configureServer(server) {
125
+ // Watch for data file changes in dev mode
126
+ const dataGlob = GLOB_PATTERN
127
+ const watcher = server.watcher
128
+
129
+ const invalidate = (filePath) => {
130
+ const parsed = parseDataFile(filePath)
131
+ if (!parsed) return
132
+ // Rebuild index and invalidate virtual module
133
+ index = null
134
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
135
+ if (mod) {
136
+ server.moduleGraph.invalidateModule(mod)
137
+ server.ws.send({ type: 'full-reload' })
138
+ }
139
+ }
140
+
141
+ watcher.on('add', invalidate)
142
+ watcher.on('unlink', invalidate)
143
+ watcher.on('change', invalidate)
144
+ },
145
+
146
+ // Rebuild index on each build start
147
+ buildStart() {
148
+ index = null
149
+ },
150
+ }
151
+ }
@@ -0,0 +1,127 @@
1
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import path from 'node:path'
4
+ import storyboardDataPlugin from './data-plugin.js'
5
+
6
+ const RESOLVED_ID = '\0virtual:storyboard-data-index'
7
+
8
+ let tmpDir
9
+
10
+ beforeEach(() => {
11
+ tmpDir = mkdtempSync(path.join(tmpdir(), 'sb-test-'))
12
+ })
13
+
14
+ afterEach(() => {
15
+ rmSync(tmpDir, { recursive: true, force: true })
16
+ })
17
+
18
+ function createPlugin(root) {
19
+ const plugin = storyboardDataPlugin()
20
+ plugin.configResolved({ root: root ?? tmpDir })
21
+ return plugin
22
+ }
23
+
24
+ function writeDataFiles(dir) {
25
+ writeFileSync(
26
+ path.join(dir, 'default.scene.json'),
27
+ JSON.stringify({ title: 'Test' }),
28
+ )
29
+ writeFileSync(
30
+ path.join(dir, 'user.object.json'),
31
+ JSON.stringify({ name: 'Jane' }),
32
+ )
33
+ writeFileSync(
34
+ path.join(dir, 'posts.record.json'),
35
+ JSON.stringify([{ id: '1', title: 'First' }]),
36
+ )
37
+ }
38
+
39
+ describe('storyboardDataPlugin', () => {
40
+ it("has name 'storyboard-data'", () => {
41
+ const plugin = storyboardDataPlugin()
42
+ expect(plugin.name).toBe('storyboard-data')
43
+ })
44
+
45
+ it("has enforce 'pre'", () => {
46
+ const plugin = storyboardDataPlugin()
47
+ expect(plugin.enforce).toBe('pre')
48
+ })
49
+
50
+ it("resolveId returns resolved ID for 'virtual:storyboard-data-index'", () => {
51
+ const plugin = createPlugin()
52
+ expect(plugin.resolveId('virtual:storyboard-data-index')).toBe(RESOLVED_ID)
53
+ })
54
+
55
+ it('resolveId returns undefined for other IDs', () => {
56
+ const plugin = createPlugin()
57
+ expect(plugin.resolveId('some-other-module')).toBeUndefined()
58
+ })
59
+
60
+ it('load generates valid module code with init() call', () => {
61
+ writeDataFiles(tmpDir)
62
+ const plugin = createPlugin()
63
+ const code = plugin.load(RESOLVED_ID)
64
+
65
+ expect(code).toContain("import { init } from '@dfosco/storyboard-core'")
66
+ expect(code).toContain('init({ scenes, objects, records })')
67
+ expect(code).toContain('"Test"')
68
+ expect(code).toContain('"Jane"')
69
+ expect(code).toContain('"First"')
70
+ })
71
+
72
+ it('load returns null for other IDs', () => {
73
+ const plugin = createPlugin()
74
+ expect(plugin.load('other-id')).toBeNull()
75
+ })
76
+
77
+ it('duplicate data files throw an error', () => {
78
+ writeFileSync(
79
+ path.join(tmpDir, 'dup.scene.json'),
80
+ JSON.stringify({ a: 1 }),
81
+ )
82
+ const subDir = path.join(tmpDir, 'nested')
83
+ mkdirSync(subDir, { recursive: true })
84
+ writeFileSync(
85
+ path.join(subDir, 'dup.scene.json'),
86
+ JSON.stringify({ a: 2 }),
87
+ )
88
+
89
+ const plugin = createPlugin()
90
+ expect(() => plugin.load(RESOLVED_ID)).toThrow(/Duplicate data file/)
91
+ })
92
+
93
+ it('handles JSONC files (with comments)', () => {
94
+ writeFileSync(
95
+ path.join(tmpDir, 'commented.scene.jsonc'),
96
+ '{\n // This is a comment\n "title": "JSONC Scene"\n}\n',
97
+ )
98
+ const plugin = createPlugin()
99
+ const code = plugin.load(RESOLVED_ID)
100
+
101
+ expect(code).toContain('"JSONC Scene"')
102
+ })
103
+
104
+ it('buildStart resets the index cache', () => {
105
+ writeDataFiles(tmpDir)
106
+ const plugin = createPlugin()
107
+
108
+ // First load builds the index
109
+ const code1 = plugin.load(RESOLVED_ID)
110
+ expect(code1).toContain('"Test"')
111
+
112
+ // Add a new file
113
+ writeFileSync(
114
+ path.join(tmpDir, 'extra.scene.json'),
115
+ JSON.stringify({ title: 'Extra' }),
116
+ )
117
+
118
+ // Without buildStart, cached index is used — "Extra" won't appear
119
+ const code2 = plugin.load(RESOLVED_ID)
120
+ expect(code2).not.toContain('"Extra"')
121
+
122
+ // After buildStart, index is rebuilt
123
+ plugin.buildStart()
124
+ const code3 = plugin.load(RESOLVED_ID)
125
+ expect(code3).toContain('"Extra"')
126
+ })
127
+ })