@dfosco/storyboard-react 2.1.0 → 2.3.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 +6 -4
- package/src/context.jsx +3 -2
- package/src/context.test.jsx +23 -1
- package/src/hooks/useRecord.js +19 -6
- package/src/hooks/useRecord.test.js +92 -1
- package/src/hooks/useScene.js +3 -2
- package/src/vite/data-plugin.js +96 -24
- package/src/vite/data-plugin.test.js +106 -3
package/package.json
CHANGED
package/src/Viewfinder.jsx
CHANGED
|
@@ -15,12 +15,14 @@ import { useRef, useEffect } from 'react'
|
|
|
15
15
|
* @param {string} [props.title] - Header title
|
|
16
16
|
* @param {string} [props.subtitle] - Optional subtitle
|
|
17
17
|
* @param {boolean} [props.showThumbnails] - Show thumbnail previews
|
|
18
|
-
* @param {boolean} [props.
|
|
18
|
+
* @param {boolean} [props.hideDefaultFlow] - Hide the "default" flow from the "Other flows" section
|
|
19
19
|
*/
|
|
20
|
-
export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyboard', subtitle, showThumbnails = false, hideDefaultScene = false }) {
|
|
20
|
+
export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyboard', subtitle, showThumbnails = false, hideDefaultFlow, hideDefaultScene = false }) {
|
|
21
21
|
const containerRef = useRef(null)
|
|
22
22
|
const handleRef = useRef(null)
|
|
23
23
|
|
|
24
|
+
const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
|
|
25
|
+
|
|
24
26
|
const knownRoutes = Object.keys(pageModules)
|
|
25
27
|
.map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
|
|
26
28
|
.filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder')
|
|
@@ -40,7 +42,7 @@ export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyb
|
|
|
40
42
|
basePath,
|
|
41
43
|
knownRoutes,
|
|
42
44
|
showThumbnails,
|
|
43
|
-
hideDefaultFlow:
|
|
45
|
+
hideDefaultFlow: shouldHideDefault,
|
|
44
46
|
})
|
|
45
47
|
})
|
|
46
48
|
|
|
@@ -51,7 +53,7 @@ export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyb
|
|
|
51
53
|
handleRef.current = null
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
|
-
}, [title, subtitle, basePath, showThumbnails,
|
|
56
|
+
}, [title, subtitle, basePath, showThumbnails, shouldHideDefault])
|
|
55
57
|
|
|
56
58
|
return <div ref={containerRef} style={{ minHeight: '100vh' }} />
|
|
57
59
|
}
|
package/src/context.jsx
CHANGED
|
@@ -32,7 +32,7 @@ function getPageFlowName(pathname) {
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Provides loaded flow data to the component tree.
|
|
35
|
-
* Reads the flow name from the ?
|
|
35
|
+
* Reads the flow name from the ?flow= URL param (with ?scene= as alias),
|
|
36
36
|
* a matching flow file for the current page, or defaults to "default".
|
|
37
37
|
*
|
|
38
38
|
* Derives the prototype scope from the route and uses it to resolve
|
|
@@ -43,7 +43,8 @@ function getPageFlowName(pathname) {
|
|
|
43
43
|
*/
|
|
44
44
|
export default function StoryboardProvider({ flowName, sceneName, recordName, recordParam, children }) {
|
|
45
45
|
const location = useLocation()
|
|
46
|
-
const
|
|
46
|
+
const searchParams = new URLSearchParams(location.search)
|
|
47
|
+
const sceneParam = searchParams.get('flow') || searchParams.get('scene')
|
|
47
48
|
const prototypeName = getPrototypeName(location.pathname)
|
|
48
49
|
const pageFlow = getPageFlowName(location.pathname)
|
|
49
50
|
const params = useParams()
|
package/src/context.test.jsx
CHANGED
|
@@ -187,7 +187,18 @@ describe('StoryboardProvider', () => {
|
|
|
187
187
|
expect(screen.getByTestId('nav')).toHaveTextContent('Home,Repos')
|
|
188
188
|
})
|
|
189
189
|
|
|
190
|
-
it('reads ?
|
|
190
|
+
it('reads ?flow= param from location.search', () => {
|
|
191
|
+
mockUseLocation.mockReturnValue({ pathname: '/whatever', search: '?flow=other', hash: '' })
|
|
192
|
+
|
|
193
|
+
render(
|
|
194
|
+
<StoryboardProvider>
|
|
195
|
+
<ContextReader path="title" />
|
|
196
|
+
</StoryboardProvider>,
|
|
197
|
+
)
|
|
198
|
+
expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('reads ?scene= as alias for ?flow=', () => {
|
|
191
202
|
mockUseLocation.mockReturnValue({ pathname: '/whatever', search: '?scene=other', hash: '' })
|
|
192
203
|
|
|
193
204
|
render(
|
|
@@ -198,6 +209,17 @@ describe('StoryboardProvider', () => {
|
|
|
198
209
|
expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
|
|
199
210
|
})
|
|
200
211
|
|
|
212
|
+
it('prefers ?flow= over ?scene= when both present', () => {
|
|
213
|
+
mockUseLocation.mockReturnValue({ pathname: '/whatever', search: '?flow=other&scene=default', hash: '' })
|
|
214
|
+
|
|
215
|
+
render(
|
|
216
|
+
<StoryboardProvider>
|
|
217
|
+
<ContextReader path="title" />
|
|
218
|
+
</StoryboardProvider>,
|
|
219
|
+
)
|
|
220
|
+
expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
|
|
221
|
+
})
|
|
222
|
+
|
|
201
223
|
it('loads prototype flow for sub-pages when no page-specific flow exists', () => {
|
|
202
224
|
init({
|
|
203
225
|
flows: {
|
package/src/hooks/useRecord.js
CHANGED
|
@@ -20,15 +20,26 @@ import { StoryboardContext } from '../StoryboardContext.js'
|
|
|
20
20
|
* - Unknown ids create new entries appended to the array.
|
|
21
21
|
*
|
|
22
22
|
* @param {Array} baseRecords - The original record array (will be deep-cloned)
|
|
23
|
-
* @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.
|
|
24
25
|
* @returns {Array} Merged array
|
|
25
26
|
*/
|
|
26
|
-
function applyRecordOverrides(baseRecords,
|
|
27
|
+
function applyRecordOverrides(baseRecords, resolvedName, plainName) {
|
|
27
28
|
const allParams = isHideMode() ? getAllShadows() : getAllParams()
|
|
28
|
-
|
|
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
|
|
29
38
|
|
|
30
39
|
// Collect only the params that target this record
|
|
31
|
-
const overrideKeys = Object.keys(allParams).filter(k =>
|
|
40
|
+
const overrideKeys = Object.keys(allParams).filter(k =>
|
|
41
|
+
k.startsWith(resolvedPrefix) || (plainPrefix && k.startsWith(plainPrefix))
|
|
42
|
+
)
|
|
32
43
|
if (overrideKeys.length === 0) return baseRecords
|
|
33
44
|
|
|
34
45
|
const records = deepClone(baseRecords)
|
|
@@ -37,6 +48,8 @@ function applyRecordOverrides(baseRecords, recordName) {
|
|
|
37
48
|
// key format: record.{name}.{entryId}.{field...}
|
|
38
49
|
const byEntryId = {}
|
|
39
50
|
for (const key of overrideKeys) {
|
|
51
|
+
// Determine which prefix matched to slice correctly
|
|
52
|
+
const prefix = key.startsWith(resolvedPrefix) ? resolvedPrefix : plainPrefix
|
|
40
53
|
const rest = key.slice(prefix.length) // "{entryId}.{field...}"
|
|
41
54
|
const dotIdx = rest.indexOf('.')
|
|
42
55
|
if (dotIdx === -1) continue // no field path — skip
|
|
@@ -104,7 +117,7 @@ export function useRecord(recordName, paramName = 'id') {
|
|
|
104
117
|
try {
|
|
105
118
|
const resolvedName = resolveRecordName(prototypeName, recordName)
|
|
106
119
|
const base = loadRecord(resolvedName)
|
|
107
|
-
const merged = applyRecordOverrides(base, resolvedName)
|
|
120
|
+
const merged = applyRecordOverrides(base, resolvedName, recordName)
|
|
108
121
|
return merged.find(e => e[paramName] === paramValue) ?? null
|
|
109
122
|
} catch (err) {
|
|
110
123
|
console.error(`[useRecord] ${err.message}`)
|
|
@@ -136,7 +149,7 @@ export function useRecords(recordName) {
|
|
|
136
149
|
try {
|
|
137
150
|
const resolvedName = resolveRecordName(prototypeName, recordName)
|
|
138
151
|
const base = loadRecord(resolvedName)
|
|
139
|
-
return applyRecordOverrides(base, resolvedName)
|
|
152
|
+
return applyRecordOverrides(base, resolvedName, recordName)
|
|
140
153
|
} catch (err) {
|
|
141
154
|
console.error(`[useRecords] ${err.message}`)
|
|
142
155
|
return []
|
|
@@ -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
|
@@ -6,7 +6,7 @@ import { StoryboardContext } from '../StoryboardContext.js'
|
|
|
6
6
|
*
|
|
7
7
|
* @returns {{ flowName: string, switchFlow: (name: string) => void }}
|
|
8
8
|
* - flowName – current active flow (e.g. "default")
|
|
9
|
-
* - switchFlow – navigate to a different flow by updating ?
|
|
9
|
+
* - switchFlow – navigate to a different flow by updating ?flow= param
|
|
10
10
|
*/
|
|
11
11
|
export function useFlow() {
|
|
12
12
|
const context = useContext(StoryboardContext)
|
|
@@ -16,7 +16,8 @@ export function useFlow() {
|
|
|
16
16
|
|
|
17
17
|
const switchFlow = useCallback((name) => {
|
|
18
18
|
const url = new URL(window.location.href)
|
|
19
|
-
url.searchParams.
|
|
19
|
+
url.searchParams.delete('scene')
|
|
20
|
+
url.searchParams.set('flow', name)
|
|
20
21
|
// Preserve hash params across flow switches
|
|
21
22
|
window.location.href = url.toString()
|
|
22
23
|
}, [])
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -7,39 +7,55 @@ import { parse as parseJsonc } from 'jsonc-parser'
|
|
|
7
7
|
const VIRTUAL_MODULE_ID = 'virtual:storyboard-data-index'
|
|
8
8
|
const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
|
|
9
9
|
|
|
10
|
-
const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype}.{json,jsonc}'
|
|
10
|
+
const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype,folder}.{json,jsonc}'
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Extract the data name and type suffix from a file path.
|
|
14
14
|
* Flows and records inside src/prototypes/{Name}/ get prefixed with the
|
|
15
15
|
* prototype name (e.g. "Dashboard/default"). Objects are never prefixed.
|
|
16
|
+
* Directories ending in .folder/ are skipped when extracting prototype scope.
|
|
16
17
|
*
|
|
17
18
|
* e.g. "src/data/default.flow.json" → { name: "default", suffix: "flow" }
|
|
18
19
|
* "src/prototypes/Dashboard/default.flow.json" → { name: "Dashboard/default", suffix: "flow" }
|
|
19
20
|
* "src/prototypes/Dashboard/helpers.object.json"→ { name: "helpers", suffix: "object" }
|
|
21
|
+
* "src/prototypes/X.folder/Dashboard/default.flow.json" → { name: "Dashboard/default", suffix: "flow", folder: "X" }
|
|
20
22
|
*/
|
|
21
23
|
function parseDataFile(filePath) {
|
|
22
24
|
const base = path.basename(filePath)
|
|
23
|
-
const match = base.match(/^(.+)\.(flow|scene|object|record|prototype)\.(jsonc?)$/)
|
|
25
|
+
const match = base.match(/^(.+)\.(flow|scene|object|record|prototype|folder)\.(jsonc?)$/)
|
|
24
26
|
if (!match) return null
|
|
25
27
|
// Normalize .scene → .flow for backward compatibility
|
|
26
28
|
const suffix = match[2] === 'scene' ? 'flow' : match[2]
|
|
27
29
|
let name = match[1]
|
|
28
30
|
|
|
31
|
+
const normalized = filePath.replace(/\\/g, '/')
|
|
32
|
+
|
|
33
|
+
// Detect if this file is inside a .folder/ directory
|
|
34
|
+
const folderDirMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\.folder\//)
|
|
35
|
+
const folderName = folderDirMatch ? folderDirMatch[1] : null
|
|
36
|
+
|
|
37
|
+
// Folder metadata files are keyed by their folder directory name (sans .folder suffix)
|
|
38
|
+
if (suffix === 'folder') {
|
|
39
|
+
if (folderName) {
|
|
40
|
+
name = folderName
|
|
41
|
+
}
|
|
42
|
+
return { name, suffix, ext: match[3] }
|
|
43
|
+
}
|
|
44
|
+
|
|
29
45
|
// Prototype metadata files are keyed by their prototype directory name
|
|
46
|
+
// (skip .folder/ segments when determining prototype name)
|
|
30
47
|
if (suffix === 'prototype') {
|
|
31
|
-
const
|
|
32
|
-
const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\//)
|
|
48
|
+
const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/(?:[^/]+\.folder\/)?([^/]+)\//)
|
|
33
49
|
if (protoMatch) {
|
|
34
50
|
name = protoMatch[1]
|
|
35
51
|
}
|
|
36
|
-
return { name, suffix, ext: match[3] }
|
|
52
|
+
return { name, suffix, ext: match[3], folder: folderName }
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
// Scope flows and records inside src/prototypes/{Name}/ with a prefix
|
|
56
|
+
// (skip .folder/ segments when determining prototype name)
|
|
40
57
|
if (suffix !== 'object') {
|
|
41
|
-
const
|
|
42
|
-
const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\//)
|
|
58
|
+
const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/(?:[^/]+\.folder\/)?([^/]+)\//)
|
|
43
59
|
if (protoMatch) {
|
|
44
60
|
name = `${protoMatch[1]}/${name}`
|
|
45
61
|
}
|
|
@@ -65,6 +81,22 @@ function getGitAuthor(root, filePath) {
|
|
|
65
81
|
}
|
|
66
82
|
}
|
|
67
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Look up the most recent commit date for any file in a directory.
|
|
86
|
+
* Returns an ISO 8601 timestamp, or null if unavailable.
|
|
87
|
+
*/
|
|
88
|
+
function getLastModified(root, dirPath) {
|
|
89
|
+
try {
|
|
90
|
+
const result = execSync(
|
|
91
|
+
`git log -1 --format="%aI" -- "${dirPath}"`,
|
|
92
|
+
{ cwd: root, encoding: 'utf-8', timeout: 5000 },
|
|
93
|
+
).trim()
|
|
94
|
+
return result || null
|
|
95
|
+
} catch {
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
68
100
|
/**
|
|
69
101
|
* Scan the repo for all data files, validate uniqueness, return the index.
|
|
70
102
|
*/
|
|
@@ -72,8 +104,24 @@ function buildIndex(root) {
|
|
|
72
104
|
const ignore = ['node_modules/**', 'dist/**', '.git/**']
|
|
73
105
|
const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
|
|
74
106
|
|
|
75
|
-
|
|
107
|
+
// Detect nested .folder/ directories (not supported)
|
|
108
|
+
// Scan directories directly since empty nested folders have no data files
|
|
109
|
+
const folderDirs = globSync('src/prototypes/**/*.folder', { cwd: root, ignore, absolute: false })
|
|
110
|
+
for (const dir of folderDirs) {
|
|
111
|
+
const normalized = dir.replace(/\\/g, '/')
|
|
112
|
+
const segments = normalized.split('/').filter(s => s.endsWith('.folder'))
|
|
113
|
+
if (segments.length > 1) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`[storyboard-data] Nested .folder directories are not supported.\n` +
|
|
116
|
+
` Found at: ${dir}\n` +
|
|
117
|
+
` Folders can only be one level deep inside src/prototypes/.`
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {} }
|
|
76
123
|
const seen = {} // "name.suffix" → absolute path (for duplicate detection)
|
|
124
|
+
const protoFolders = {} // prototype name → folder name (for injection)
|
|
77
125
|
|
|
78
126
|
for (const relPath of files) {
|
|
79
127
|
const parsed = parseDataFile(relPath)
|
|
@@ -86,8 +134,10 @@ function buildIndex(root) {
|
|
|
86
134
|
const hint = parsed.suffix === 'object'
|
|
87
135
|
? ' Objects are globally scoped — even inside src/prototypes/ they share a single namespace.\n' +
|
|
88
136
|
' Rename one of the files to avoid the collision.'
|
|
89
|
-
:
|
|
90
|
-
'
|
|
137
|
+
: parsed.suffix === 'folder'
|
|
138
|
+
? ' Folder names must be unique across the project.'
|
|
139
|
+
: ' Flows and records are scoped to their prototype directory.\n' +
|
|
140
|
+
' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
|
|
91
141
|
|
|
92
142
|
throw new Error(
|
|
93
143
|
`[storyboard-data] Duplicate ${parsed.suffix} "${parsed.name}"\n` +
|
|
@@ -99,9 +149,14 @@ function buildIndex(root) {
|
|
|
99
149
|
|
|
100
150
|
seen[key] = absPath
|
|
101
151
|
index[parsed.suffix][parsed.name] = absPath
|
|
152
|
+
|
|
153
|
+
// Track which folder a prototype belongs to
|
|
154
|
+
if (parsed.suffix === 'prototype' && parsed.folder) {
|
|
155
|
+
protoFolders[parsed.name] = parsed.folder
|
|
156
|
+
}
|
|
102
157
|
}
|
|
103
158
|
|
|
104
|
-
return index
|
|
159
|
+
return { index, protoFolders }
|
|
105
160
|
}
|
|
106
161
|
|
|
107
162
|
/**
|
|
@@ -164,10 +219,10 @@ function readModesConfig(root) {
|
|
|
164
219
|
return fallback
|
|
165
220
|
}
|
|
166
221
|
|
|
167
|
-
function generateModule(index, root) {
|
|
222
|
+
function generateModule({ index, protoFolders }, root) {
|
|
168
223
|
const declarations = []
|
|
169
|
-
const INDEX_KEYS = ['flow', 'object', 'record', 'prototype']
|
|
170
|
-
const entries = { flow: [], object: [], record: [], prototype: [] }
|
|
224
|
+
const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder']
|
|
225
|
+
const entries = { flow: [], object: [], record: [], prototype: [], folder: [] }
|
|
171
226
|
let i = 0
|
|
172
227
|
|
|
173
228
|
for (const suffix of INDEX_KEYS) {
|
|
@@ -184,13 +239,27 @@ function generateModule(index, root) {
|
|
|
184
239
|
}
|
|
185
240
|
}
|
|
186
241
|
|
|
242
|
+
// Auto-fill lastModified from git history for prototypes
|
|
243
|
+
if (suffix === 'prototype' && parsed) {
|
|
244
|
+
const protoDir = path.dirname(absPath)
|
|
245
|
+
const lastModified = getLastModified(root, protoDir)
|
|
246
|
+
if (lastModified) {
|
|
247
|
+
parsed = { ...parsed, lastModified }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Inject folder association into prototype metadata
|
|
252
|
+
if (suffix === 'prototype' && protoFolders[name]) {
|
|
253
|
+
parsed = { ...parsed, folder: protoFolders[name] }
|
|
254
|
+
}
|
|
255
|
+
|
|
187
256
|
declarations.push(`const ${varName} = ${JSON.stringify(parsed)}`)
|
|
188
257
|
entries[suffix].push(` ${JSON.stringify(name)}: ${varName}`)
|
|
189
258
|
}
|
|
190
259
|
}
|
|
191
260
|
|
|
192
261
|
const imports = [`import { init } from '@dfosco/storyboard-core'`]
|
|
193
|
-
const initCalls = [`init({ flows, objects, records, prototypes })`]
|
|
262
|
+
const initCalls = [`init({ flows, objects, records, prototypes, folders })`]
|
|
194
263
|
|
|
195
264
|
// Feature flags from storyboard.config.json
|
|
196
265
|
const { config } = readConfig(root)
|
|
@@ -237,14 +306,15 @@ function generateModule(index, root) {
|
|
|
237
306
|
`const objects = {\n${entries.object.join(',\n')}\n}`,
|
|
238
307
|
`const records = {\n${entries.record.join(',\n')}\n}`,
|
|
239
308
|
`const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
|
|
309
|
+
`const folders = {\n${entries.folder.join(',\n')}\n}`,
|
|
240
310
|
'',
|
|
241
311
|
'// Backward-compatible alias',
|
|
242
312
|
'const scenes = flows',
|
|
243
313
|
'',
|
|
244
314
|
initCalls.join('\n'),
|
|
245
315
|
'',
|
|
246
|
-
`export { flows, scenes, objects, records, prototypes }`,
|
|
247
|
-
`export const index = { flows, scenes, objects, records, prototypes }`,
|
|
316
|
+
`export { flows, scenes, objects, records, prototypes, folders }`,
|
|
317
|
+
`export const index = { flows, scenes, objects, records, prototypes, folders }`,
|
|
248
318
|
`export default index`,
|
|
249
319
|
].join('\n')
|
|
250
320
|
}
|
|
@@ -259,7 +329,7 @@ function generateModule(index, root) {
|
|
|
259
329
|
*/
|
|
260
330
|
export default function storyboardDataPlugin() {
|
|
261
331
|
let root = ''
|
|
262
|
-
let
|
|
332
|
+
let buildResult = null
|
|
263
333
|
|
|
264
334
|
return {
|
|
265
335
|
name: 'storyboard-data',
|
|
@@ -283,8 +353,8 @@ export default function storyboardDataPlugin() {
|
|
|
283
353
|
|
|
284
354
|
load(id) {
|
|
285
355
|
if (id !== RESOLVED_ID) return null
|
|
286
|
-
if (!
|
|
287
|
-
return generateModule(
|
|
356
|
+
if (!buildResult) buildResult = buildIndex(root)
|
|
357
|
+
return generateModule(buildResult, root)
|
|
288
358
|
},
|
|
289
359
|
|
|
290
360
|
configureServer(server) {
|
|
@@ -293,9 +363,11 @@ export default function storyboardDataPlugin() {
|
|
|
293
363
|
|
|
294
364
|
const invalidate = (filePath) => {
|
|
295
365
|
const parsed = parseDataFile(filePath)
|
|
296
|
-
|
|
366
|
+
// Also invalidate when files are added/removed inside .folder/ directories
|
|
367
|
+
const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
|
|
368
|
+
if (!parsed && !inFolder) return
|
|
297
369
|
// Rebuild index and invalidate virtual module
|
|
298
|
-
|
|
370
|
+
buildResult = null
|
|
299
371
|
const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
|
|
300
372
|
if (mod) {
|
|
301
373
|
server.moduleGraph.invalidateModule(mod)
|
|
@@ -308,7 +380,7 @@ export default function storyboardDataPlugin() {
|
|
|
308
380
|
watcher.add(configPath)
|
|
309
381
|
const invalidateConfig = (filePath) => {
|
|
310
382
|
if (path.resolve(filePath) === configPath) {
|
|
311
|
-
|
|
383
|
+
buildResult = null
|
|
312
384
|
const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
|
|
313
385
|
if (mod) {
|
|
314
386
|
server.moduleGraph.invalidateModule(mod)
|
|
@@ -327,7 +399,7 @@ export default function storyboardDataPlugin() {
|
|
|
327
399
|
|
|
328
400
|
// Rebuild index on each build start
|
|
329
401
|
buildStart() {
|
|
330
|
-
|
|
402
|
+
buildResult = null
|
|
331
403
|
},
|
|
332
404
|
}
|
|
333
405
|
}
|
|
@@ -69,13 +69,13 @@ describe('storyboardDataPlugin', () => {
|
|
|
69
69
|
const code = plugin.load(RESOLVED_ID)
|
|
70
70
|
|
|
71
71
|
expect(code).toContain("import { init } from '@dfosco/storyboard-core'")
|
|
72
|
-
expect(code).toContain('init({ flows, objects, records, prototypes })')
|
|
72
|
+
expect(code).toContain('init({ flows, objects, records, prototypes, folders })')
|
|
73
73
|
expect(code).toContain('"Test"')
|
|
74
74
|
expect(code).toContain('"Jane"')
|
|
75
75
|
expect(code).toContain('"First"')
|
|
76
76
|
// Backward-compat alias
|
|
77
77
|
expect(code).toContain('const scenes = flows')
|
|
78
|
-
expect(code).toContain('export { flows, scenes, objects, records, prototypes }')
|
|
78
|
+
expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders }')
|
|
79
79
|
})
|
|
80
80
|
|
|
81
81
|
it('load returns null for other IDs', () => {
|
|
@@ -137,7 +137,7 @@ describe('storyboardDataPlugin', () => {
|
|
|
137
137
|
|
|
138
138
|
// .scene.json files should be normalized to the flows category
|
|
139
139
|
expect(code).toContain('"Legacy Scene"')
|
|
140
|
-
expect(code).toContain('init({ flows, objects, records, prototypes })')
|
|
140
|
+
expect(code).toContain('init({ flows, objects, records, prototypes, folders })')
|
|
141
141
|
})
|
|
142
142
|
|
|
143
143
|
it('buildStart resets the index cache', () => {
|
|
@@ -264,3 +264,106 @@ describe('prototype scoping', () => {
|
|
|
264
264
|
expect(code).toContain('flows')
|
|
265
265
|
})
|
|
266
266
|
})
|
|
267
|
+
|
|
268
|
+
describe('folder grouping', () => {
|
|
269
|
+
it('discovers .folder.json files and keys them by folder directory name', () => {
|
|
270
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Getting Started.folder'), { recursive: true })
|
|
271
|
+
writeFileSync(
|
|
272
|
+
path.join(tmpDir, 'src', 'prototypes', 'Getting Started.folder', 'getting-started.folder.json'),
|
|
273
|
+
JSON.stringify({ meta: { title: 'Getting Started', description: 'Intro' } }),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
const plugin = createPlugin()
|
|
277
|
+
const code = plugin.load(RESOLVED_ID)
|
|
278
|
+
|
|
279
|
+
expect(code).toContain('"Getting Started"')
|
|
280
|
+
expect(code).toContain('"Intro"')
|
|
281
|
+
expect(code).toContain('folders')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('scopes prototypes inside .folder/ directories correctly', () => {
|
|
285
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'MyFolder.folder', 'Dashboard'), { recursive: true })
|
|
286
|
+
writeFileSync(
|
|
287
|
+
path.join(tmpDir, 'src', 'prototypes', 'MyFolder.folder', 'Dashboard', 'default.flow.json'),
|
|
288
|
+
JSON.stringify({ title: 'Dashboard Default' }),
|
|
289
|
+
)
|
|
290
|
+
writeFileSync(
|
|
291
|
+
path.join(tmpDir, 'src', 'prototypes', 'MyFolder.folder', 'Dashboard', 'dashboard.prototype.json'),
|
|
292
|
+
JSON.stringify({ meta: { title: 'Dashboard' } }),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
const plugin = createPlugin()
|
|
296
|
+
const code = plugin.load(RESOLVED_ID)
|
|
297
|
+
|
|
298
|
+
// Flow should be scoped to prototype, not folder
|
|
299
|
+
expect(code).toContain('"Dashboard/default"')
|
|
300
|
+
expect(code).not.toContain('"MyFolder.folder/default"')
|
|
301
|
+
expect(code).not.toContain('"MyFolder/default"')
|
|
302
|
+
// Prototype should have folder field injected
|
|
303
|
+
expect(code).toContain('"folder":"MyFolder"')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('does NOT prefix objects inside .folder/ directories', () => {
|
|
307
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Proto'), { recursive: true })
|
|
308
|
+
writeFileSync(
|
|
309
|
+
path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Proto', 'helpers.object.json'),
|
|
310
|
+
JSON.stringify({ util: true }),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
const plugin = createPlugin()
|
|
314
|
+
const code = plugin.load(RESOLVED_ID)
|
|
315
|
+
|
|
316
|
+
expect(code).toContain('"helpers"')
|
|
317
|
+
expect(code).not.toContain('"X/helpers"')
|
|
318
|
+
expect(code).not.toContain('"Proto/helpers"')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('scopes records inside .folder/ directories to their prototype', () => {
|
|
322
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Blog'), { recursive: true })
|
|
323
|
+
writeFileSync(
|
|
324
|
+
path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Blog', 'posts.record.json'),
|
|
325
|
+
JSON.stringify([{ id: '1', title: 'Post' }]),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
const plugin = createPlugin()
|
|
329
|
+
const code = plugin.load(RESOLVED_ID)
|
|
330
|
+
|
|
331
|
+
expect(code).toContain('"Blog/posts"')
|
|
332
|
+
expect(code).not.toContain('"X/posts"')
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('allows prototypes with same name in different folders without clash', () => {
|
|
336
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'A.folder', 'Settings'), { recursive: true })
|
|
337
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'B.folder', 'Settings'), { recursive: true })
|
|
338
|
+
writeFileSync(
|
|
339
|
+
path.join(tmpDir, 'src', 'prototypes', 'A.folder', 'Settings', 'default.flow.json'),
|
|
340
|
+
JSON.stringify({ from: 'A' }),
|
|
341
|
+
)
|
|
342
|
+
writeFileSync(
|
|
343
|
+
path.join(tmpDir, 'src', 'prototypes', 'B.folder', 'Settings', 'default.flow.json'),
|
|
344
|
+
JSON.stringify({ from: 'B' }),
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
const plugin = createPlugin()
|
|
348
|
+
// Same flow name in same prototype name → duplicate collision
|
|
349
|
+
expect(() => plugin.load(RESOLVED_ID)).toThrow(/Duplicate flow "Settings\/default"/)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('throws on nested .folder/ directories', () => {
|
|
353
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Outer.folder', 'Inner.folder', 'Proto'), { recursive: true })
|
|
354
|
+
writeFileSync(
|
|
355
|
+
path.join(tmpDir, 'src', 'prototypes', 'Outer.folder', 'Inner.folder', 'Proto', 'default.flow.json'),
|
|
356
|
+
JSON.stringify({ title: 'Nested' }),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
const plugin = createPlugin()
|
|
360
|
+
expect(() => plugin.load(RESOLVED_ID)).toThrow(/Nested .folder directories are not supported/)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('throws on empty nested .folder/ directories', () => {
|
|
364
|
+
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Outer.folder', 'Inner.folder'), { recursive: true })
|
|
365
|
+
|
|
366
|
+
const plugin = createPlugin()
|
|
367
|
+
expect(() => plugin.load(RESOLVED_ID)).toThrow(/Nested .folder directories are not supported/)
|
|
368
|
+
})
|
|
369
|
+
})
|