@dfosco/storyboard 0.12.0 → 0.12.2
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/dist/storyboard-ui.js.map +1 -1
- package/package.json +1 -1
- package/scaffold/eslint.config.js +45 -0
- package/scaffold/scaffold.config.json +25 -0
- package/scaffold/scripts/aggregate-artifacts.mjs +23 -1
- package/scaffold/src/library/README.md +48 -3
- package/scaffold/src/library/home.jsx +24 -0
- package/scaffold/src/library/index.jsx +37 -10
- package/scaffold/src/library/routes.jsx +22 -7
- package/scaffold/src/library/workspace.jsx +12 -2
- package/scaffold/tsconfig.json +25 -0
- package/scaffold/vite.config.js +153 -0
- package/scaffold/vitest.config.js +17 -0
- package/scripts/aggregate-artifacts.mjs +23 -1
- package/src/core/cli/updateVersion.js +18 -1
- package/src/core/data/artifactIndex.js +54 -8
- package/src/core/data/artifactIndex.test.js +52 -0
- package/src/core/data/viewfinder.js +3 -2
- package/src/core/data/viewfinder.test.js +39 -0
- package/src/core/scaffold/scaffold-integration.test.js +24 -0
- package/src/core/stores/configSchema.js +98 -0
- package/src/core/stores/configSchema.test.js +75 -1
- package/src/internals/SimpleWorkspace/SimpleWorkspace.jsx +116 -20
- package/src/internals/SimpleWorkspace/SimpleWorkspace.module.css +54 -0
- package/src/internals/Viewfinder.jsx +92 -22
- package/src/internals/Viewfinder.module.css +14 -1
- package/src/internals/canvas/isolation/prototypeRoutes.filter.test.js +47 -0
- package/src/internals/canvas/isolation/prototypeRoutes.jsx +16 -18
- package/src/internals/canvas/isolation/routeFilter.js +31 -0
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +1 -1
- package/src/internals/vite/data-plugin.js +4 -0
- package/src/internals/vite/data-plugin.test.js +31 -1
|
@@ -286,6 +286,60 @@
|
|
|
286
286
|
margin-top: 32px;
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
.controls {
|
|
290
|
+
flex-basis: 100%;
|
|
291
|
+
display: flex;
|
|
292
|
+
flex-wrap: wrap;
|
|
293
|
+
justify-content: center;
|
|
294
|
+
gap: 16px;
|
|
295
|
+
margin-bottom: 4px;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.controlLabel {
|
|
299
|
+
display: inline-flex;
|
|
300
|
+
align-items: center;
|
|
301
|
+
gap: 6px;
|
|
302
|
+
font-size: 13px;
|
|
303
|
+
color: var(--color-foreground-muted, #555);
|
|
304
|
+
cursor: pointer;
|
|
305
|
+
user-select: none;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.controlCheckbox {
|
|
309
|
+
accent-color: var(--color-foreground, #1a1a1a);
|
|
310
|
+
cursor: pointer;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.folderCardButton {
|
|
314
|
+
appearance: none;
|
|
315
|
+
width: 100%;
|
|
316
|
+
border: none;
|
|
317
|
+
background: transparent;
|
|
318
|
+
font-family: inherit;
|
|
319
|
+
text-align: left;
|
|
320
|
+
cursor: pointer;
|
|
321
|
+
border-radius: 8px;
|
|
322
|
+
transition: background 0.12s;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.folderCardButton:hover {
|
|
326
|
+
background: var(--color-background, #fff);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.folderChevron {
|
|
330
|
+
display: inline-flex;
|
|
331
|
+
align-items: center;
|
|
332
|
+
justify-content: center;
|
|
333
|
+
color: var(--color-foreground-muted, #656d76);
|
|
334
|
+
flex-shrink: 0;
|
|
335
|
+
transition: transform 0.12s ease;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.folderChevronExpanded {
|
|
339
|
+
composes: folderChevron;
|
|
340
|
+
transform: rotate(90deg);
|
|
341
|
+
}
|
|
342
|
+
|
|
289
343
|
.expandBtn {
|
|
290
344
|
appearance: none;
|
|
291
345
|
border: 1px solid var(--color-border, #d0d7de);
|
|
@@ -113,6 +113,7 @@ function useGitHubUser() {
|
|
|
113
113
|
const STARRED_KEY = 'sb-workspace-starred'
|
|
114
114
|
const GROUP_BY_FOLDERS_KEY = 'sb-workspace-group-folders'
|
|
115
115
|
const COLLAPSED_FOLDERS_KEY = 'sb-workspace-collapsed-folders'
|
|
116
|
+
const SHOW_DRAFTS_KEY = 'sb-workspace-show-drafts'
|
|
116
117
|
|
|
117
118
|
function readJSON(key, fallback) {
|
|
118
119
|
try { return JSON.parse(localStorage.getItem(key)) || fallback }
|
|
@@ -795,10 +796,22 @@ function CreateFooter() {
|
|
|
795
796
|
|
|
796
797
|
/* ─── Create Form ─── */
|
|
797
798
|
|
|
799
|
+
function slugify(value) {
|
|
800
|
+
return String(value || '')
|
|
801
|
+
.replace(/[^a-zA-Z0-9\s_-]/g, '')
|
|
802
|
+
.trim()
|
|
803
|
+
.replace(/[\s_]+/g, '-')
|
|
804
|
+
.toLowerCase()
|
|
805
|
+
.replace(/-+/g, '-')
|
|
806
|
+
.replace(/^-|-$/g, '')
|
|
807
|
+
}
|
|
808
|
+
|
|
798
809
|
function CreateForm({ type, onClose, basePath }) {
|
|
799
810
|
const [name, setName] = useState('')
|
|
811
|
+
const [nameTouched, setNameTouched] = useState(false)
|
|
800
812
|
const [title, setTitle] = useState('')
|
|
801
813
|
const [description, setDescription] = useState('')
|
|
814
|
+
const [author, setAuthor] = useState('')
|
|
802
815
|
const [url, setUrl] = useState('')
|
|
803
816
|
const [isExternal, setIsExternal] = useState(false)
|
|
804
817
|
const [prototype, setPrototype] = useState('')
|
|
@@ -810,6 +823,22 @@ function CreateForm({ type, onClose, basePath }) {
|
|
|
810
823
|
|
|
811
824
|
const needsPrototype = type === 'Flow' || type === 'Page'
|
|
812
825
|
const showPartials = type === 'Prototype' && !isExternal
|
|
826
|
+
// Types that expose a Title field derive their Name from a slug of the
|
|
827
|
+
// Title; Component/Page have no Title so Name stays the primary field.
|
|
828
|
+
const hasTitle = type !== 'Component' && type !== 'Page'
|
|
829
|
+
|
|
830
|
+
// Prefill author from the currently authed gh user (login preferred, then
|
|
831
|
+
// git user.name), mirroring the CLI `create` behavior.
|
|
832
|
+
useEffect(() => {
|
|
833
|
+
const apiBase = (basePath || '/').replace(/\/+$/, '')
|
|
834
|
+
fetch(`${apiBase}/_storyboard/git-user`)
|
|
835
|
+
.then(r => r.ok ? r.json() : null)
|
|
836
|
+
.then(data => {
|
|
837
|
+
const resolved = data?.login || data?.name
|
|
838
|
+
if (resolved) setAuthor(resolved)
|
|
839
|
+
})
|
|
840
|
+
.catch(() => {})
|
|
841
|
+
}, [basePath])
|
|
813
842
|
|
|
814
843
|
useEffect(() => {
|
|
815
844
|
if (!needsPrototype) return
|
|
@@ -831,6 +860,14 @@ function CreateForm({ type, onClose, basePath }) {
|
|
|
831
860
|
.catch(() => {})
|
|
832
861
|
}, [type, basePath])
|
|
833
862
|
|
|
863
|
+
// When the user finishes editing Title, auto-fill Name with a slug of the
|
|
864
|
+
// Title — unless they've already typed their own Name.
|
|
865
|
+
const handleTitleBlur = () => {
|
|
866
|
+
if (nameTouched) return
|
|
867
|
+
const slug = slugify(title)
|
|
868
|
+
if (slug) setName(slug)
|
|
869
|
+
}
|
|
870
|
+
|
|
834
871
|
const handleSubmit = async (e) => {
|
|
835
872
|
e.preventDefault()
|
|
836
873
|
if (!name.trim()) { setError('Name is required'); return }
|
|
@@ -849,6 +886,7 @@ function CreateForm({ type, onClose, basePath }) {
|
|
|
849
886
|
const payload = { type: apiType, name: name.trim() }
|
|
850
887
|
if (title.trim()) payload.title = title.trim()
|
|
851
888
|
if (description.trim()) payload.description = description.trim()
|
|
889
|
+
if (hasTitle && author.trim()) payload.author = author.trim()
|
|
852
890
|
if (needsPrototype && prototype) payload.prototype = prototype
|
|
853
891
|
if (type === 'Prototype' && isExternal && url.trim()) payload.url = url.trim()
|
|
854
892
|
if (type === 'Prototype' && !isExternal && partial) payload.partial = partial
|
|
@@ -875,6 +913,21 @@ function CreateForm({ type, onClose, basePath }) {
|
|
|
875
913
|
|
|
876
914
|
const typeLabels = { Canvas: 'Canvas', Prototype: 'Prototype', Component: 'Component', Flow: 'Prototype Flow', Page: 'Prototype Page', Object: 'Object', Record: 'Record' }
|
|
877
915
|
|
|
916
|
+
const nameField = (
|
|
917
|
+
<div className={css.createFormField}>
|
|
918
|
+
<label className={css.createFormLabel}>Name *</label>
|
|
919
|
+
<input
|
|
920
|
+
className={css.createFormInput}
|
|
921
|
+
value={name}
|
|
922
|
+
onChange={e => { setName(e.target.value); setNameTouched(true) }}
|
|
923
|
+
placeholder={type === 'Page' ? 'my-page' : `my-${type.toLowerCase()}`}
|
|
924
|
+
autoFocus={!needsPrototype && !hasTitle}
|
|
925
|
+
autoComplete="off"
|
|
926
|
+
spellCheck={false}
|
|
927
|
+
/>
|
|
928
|
+
</div>
|
|
929
|
+
)
|
|
930
|
+
|
|
878
931
|
return (
|
|
879
932
|
<form onSubmit={handleSubmit}>
|
|
880
933
|
<div className={css.createFormHeader}>
|
|
@@ -900,18 +953,7 @@ function CreateForm({ type, onClose, basePath }) {
|
|
|
900
953
|
</div>
|
|
901
954
|
)}
|
|
902
955
|
|
|
903
|
-
|
|
904
|
-
<label className={css.createFormLabel}>Name *</label>
|
|
905
|
-
<input
|
|
906
|
-
className={css.createFormInput}
|
|
907
|
-
value={name}
|
|
908
|
-
onChange={e => setName(e.target.value)}
|
|
909
|
-
placeholder={type === 'Page' ? 'my-page' : `my-${type.toLowerCase()}`}
|
|
910
|
-
autoFocus={!needsPrototype}
|
|
911
|
-
/>
|
|
912
|
-
</div>
|
|
913
|
-
|
|
914
|
-
{type !== 'Component' && type !== 'Page' && (
|
|
956
|
+
{hasTitle ? (
|
|
915
957
|
<>
|
|
916
958
|
<div className={css.createFormField}>
|
|
917
959
|
<label className={css.createFormLabel}>Title</label>
|
|
@@ -919,9 +961,12 @@ function CreateForm({ type, onClose, basePath }) {
|
|
|
919
961
|
className={css.createFormInput}
|
|
920
962
|
value={title}
|
|
921
963
|
onChange={e => setTitle(e.target.value)}
|
|
964
|
+
onBlur={handleTitleBlur}
|
|
922
965
|
placeholder="Optional display title"
|
|
966
|
+
autoFocus={!needsPrototype}
|
|
923
967
|
/>
|
|
924
968
|
</div>
|
|
969
|
+
{nameField}
|
|
925
970
|
<div className={css.createFormField}>
|
|
926
971
|
<label className={css.createFormLabel}>Description</label>
|
|
927
972
|
<input
|
|
@@ -932,6 +977,8 @@ function CreateForm({ type, onClose, basePath }) {
|
|
|
932
977
|
/>
|
|
933
978
|
</div>
|
|
934
979
|
</>
|
|
980
|
+
) : (
|
|
981
|
+
nameField
|
|
935
982
|
)}
|
|
936
983
|
|
|
937
984
|
{type === 'Prototype' && (
|
|
@@ -1407,6 +1454,9 @@ function WorkspaceImpl({
|
|
|
1407
1454
|
return new Set(Array.isArray(parsed) ? parsed : [])
|
|
1408
1455
|
} catch { return new Set() }
|
|
1409
1456
|
})
|
|
1457
|
+
const [showDrafts, setShowDrafts] = useState(() => {
|
|
1458
|
+
try { return localStorage.getItem(SHOW_DRAFTS_KEY) !== 'false' } catch { return true }
|
|
1459
|
+
})
|
|
1410
1460
|
const [hiddenItems, setHiddenItems] = useState(new Set())
|
|
1411
1461
|
const { starred, toggle: toggleStar } = useStarred()
|
|
1412
1462
|
const recentIds = useRecent()
|
|
@@ -1415,8 +1465,9 @@ function WorkspaceImpl({
|
|
|
1415
1465
|
const navFiltered = useMemo(() => {
|
|
1416
1466
|
let filtered = activeNav === 'all' ? allItems : allItems.filter(i => i.type === TYPE_MAP[activeNav])
|
|
1417
1467
|
if (hiddenItems.size > 0) filtered = filtered.filter(i => !hiddenItems.has(i.id))
|
|
1468
|
+
if (!showDrafts) filtered = filtered.filter(i => !i.isPrivate)
|
|
1418
1469
|
return filtered
|
|
1419
|
-
}, [allItems, activeNav, hiddenItems])
|
|
1470
|
+
}, [allItems, activeNav, hiddenItems, showDrafts])
|
|
1420
1471
|
|
|
1421
1472
|
// Filter by tab
|
|
1422
1473
|
const items = useMemo(() => {
|
|
@@ -1484,6 +1535,14 @@ function WorkspaceImpl({
|
|
|
1484
1535
|
})
|
|
1485
1536
|
}, [])
|
|
1486
1537
|
|
|
1538
|
+
const toggleShowDrafts = useCallback(() => {
|
|
1539
|
+
setShowDrafts(prev => {
|
|
1540
|
+
const next = !prev
|
|
1541
|
+
try { localStorage.setItem(SHOW_DRAFTS_KEY, String(next)) } catch { /* empty */ }
|
|
1542
|
+
return next
|
|
1543
|
+
})
|
|
1544
|
+
}, [])
|
|
1545
|
+
|
|
1487
1546
|
const handleItemDeleted = useCallback((itemId) => {
|
|
1488
1547
|
setHiddenItems(prev => new Set(prev).add(itemId))
|
|
1489
1548
|
}, [])
|
|
@@ -1631,15 +1690,26 @@ function WorkspaceImpl({
|
|
|
1631
1690
|
{t}
|
|
1632
1691
|
</button>
|
|
1633
1692
|
))}
|
|
1634
|
-
<
|
|
1635
|
-
<
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1693
|
+
<div className={css.tabOptions}>
|
|
1694
|
+
<label className={css.groupByFolders}>
|
|
1695
|
+
<input
|
|
1696
|
+
type="checkbox"
|
|
1697
|
+
className={css.groupByFoldersCheckbox}
|
|
1698
|
+
checked={groupByFolders}
|
|
1699
|
+
onChange={toggleGrouping}
|
|
1700
|
+
/>
|
|
1701
|
+
Group by folders
|
|
1702
|
+
</label>
|
|
1703
|
+
<label className={css.groupByFolders}>
|
|
1704
|
+
<input
|
|
1705
|
+
type="checkbox"
|
|
1706
|
+
className={css.groupByFoldersCheckbox}
|
|
1707
|
+
checked={showDrafts}
|
|
1708
|
+
onChange={toggleShowDrafts}
|
|
1709
|
+
/>
|
|
1710
|
+
Show drafts
|
|
1711
|
+
</label>
|
|
1712
|
+
</div>
|
|
1643
1713
|
</div>
|
|
1644
1714
|
|
|
1645
1715
|
{/* Grid */}
|
|
@@ -910,11 +910,17 @@
|
|
|
910
910
|
border-radius: 6px;
|
|
911
911
|
font-size: 16px;
|
|
912
912
|
box-sizing: border-box;
|
|
913
|
+
background: var(--color-background-inset, #fafafa);
|
|
914
|
+
color: var(--color-foreground, #1a1a1a);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
.createFormInput::placeholder {
|
|
918
|
+
color: var(--color-foreground-muted, #999);
|
|
913
919
|
}
|
|
914
920
|
|
|
915
921
|
.createFormInput:focus {
|
|
916
922
|
outline: none;
|
|
917
|
-
border-color: var(--color-
|
|
923
|
+
border-color: var(--color-border-emphasis, #999);
|
|
918
924
|
}
|
|
919
925
|
|
|
920
926
|
.createFormCheckbox {
|
|
@@ -989,6 +995,13 @@
|
|
|
989
995
|
|
|
990
996
|
/* Group by folders checkbox */
|
|
991
997
|
|
|
998
|
+
.tabOptions {
|
|
999
|
+
display: flex;
|
|
1000
|
+
align-items: center;
|
|
1001
|
+
gap: 12px;
|
|
1002
|
+
margin-left: auto;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
992
1005
|
.groupByFolders {
|
|
993
1006
|
display: flex;
|
|
994
1007
|
align-items: center;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { filterChildrenForProto } from './routeFilter.js'
|
|
3
|
+
|
|
4
|
+
// Shape mirrors what generouted's generateRegularRoutes produces: nested
|
|
5
|
+
// sub-routes live as children of the prototype's top-level path node.
|
|
6
|
+
// src/prototypes/canvas-threats/index.jsx -> { path: 'canvas-threats', children: [{ index: true }, ...] }
|
|
7
|
+
// src/prototypes/canvas-threats/threats.jsx -> a { path: 'threats' } child of 'canvas-threats'
|
|
8
|
+
const nestedTree = () => [
|
|
9
|
+
{
|
|
10
|
+
path: 'canvas-threats',
|
|
11
|
+
children: [
|
|
12
|
+
{ index: true, id: 'index' },
|
|
13
|
+
{ path: 'threats', id: 'threats' },
|
|
14
|
+
{ path: 'detail/:id', id: 'detail' },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
{ path: 'other-proto', children: [{ index: true, id: 'other' }] },
|
|
18
|
+
{ path: '*', id: 'fallback' },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
describe('filterChildrenForProto', () => {
|
|
22
|
+
it('keeps nested sub-routes of the matched prototype (regression: nested scoped routes)', () => {
|
|
23
|
+
const out = filterChildrenForProto(nestedTree(), 'canvas-threats')
|
|
24
|
+
const proto = out.find((r) => r.path === 'canvas-threats')
|
|
25
|
+
expect(proto).toBeTruthy()
|
|
26
|
+
// The nested `threats` child must survive — this was the bug.
|
|
27
|
+
expect(proto.children.map((c) => c.id)).toEqual(['index', 'threats', 'detail'])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('drops sibling prototypes but keeps the splat fallback', () => {
|
|
31
|
+
const out = filterChildrenForProto(nestedTree(), 'canvas-threats')
|
|
32
|
+
expect(out.find((r) => r.path === 'other-proto')).toBeUndefined()
|
|
33
|
+
expect(out.find((r) => r.path === '*')).toBeTruthy()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('descends through pathless layout wrappers to reach prototype routes', () => {
|
|
37
|
+
const wrapped = [
|
|
38
|
+
{ Component: () => null, children: nestedTree() },
|
|
39
|
+
{ path: '*', id: 'fallback' },
|
|
40
|
+
]
|
|
41
|
+
const out = filterChildrenForProto(wrapped, 'canvas-threats')
|
|
42
|
+
const layout = out.find((r) => !r.path && r.children)
|
|
43
|
+
const proto = layout.children.find((r) => r.path === 'canvas-threats')
|
|
44
|
+
expect(proto.children.map((c) => c.id)).toEqual(['index', 'threats', 'detail'])
|
|
45
|
+
expect(layout.children.find((r) => r.path === 'other-proto')).toBeUndefined()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
patterns,
|
|
20
20
|
} from '@generouted/react-router/core'
|
|
21
21
|
import PrototypeErrorBoundary, { ImportErrorFallback } from '../../PrototypeErrorBoundary.jsx'
|
|
22
|
+
import { filterChildrenForProto } from './routeFilter.js'
|
|
22
23
|
|
|
23
24
|
// Strip src/library/, src/prototypes/, .folder/, and `drafts/` segments
|
|
24
25
|
// from route paths. `_app.jsx` lives in src/library/ (since
|
|
@@ -111,6 +112,20 @@ const fallback = { path: '*', Component: _404?.default || Fragment }
|
|
|
111
112
|
|
|
112
113
|
export const routes = [{ ...app, children: [...regularRoutes, fallback] }]
|
|
113
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Filter a route tree's children down to a single prototype subtree.
|
|
117
|
+
*
|
|
118
|
+
* Pure helper (no module-level globs) so it's unit-testable. Keeps `*` and
|
|
119
|
+
* pathless layout wrappers (descending into the latter to reach the prototype
|
|
120
|
+
* routes). Any route WITH a path is kept iff its first segment matches the
|
|
121
|
+
* requested proto — and when it matches we keep its ENTIRE subtree intact,
|
|
122
|
+
* since generouted nests sub-routes under the prototype (e.g.
|
|
123
|
+
* `canvas-threats/threats` → a `threats` child of `canvas-threats`).
|
|
124
|
+
* Re-filtering those children by first segment would drop them because their
|
|
125
|
+
* own path segment isn't the proto name — that was the nested-route bug.
|
|
126
|
+
*/
|
|
127
|
+
export { filterChildrenForProto } from './routeFilter.js'
|
|
128
|
+
|
|
114
129
|
/**
|
|
115
130
|
* Return a routes tree filtered to a single prototype subtree. Used by the
|
|
116
131
|
* per-prototype iframe entry — filtering at this layer keeps a broken
|
|
@@ -119,22 +134,5 @@ export const routes = [{ ...app, children: [...regularRoutes, fallback] }]
|
|
|
119
134
|
*/
|
|
120
135
|
export function getRoutesForProto(proto) {
|
|
121
136
|
if (!proto) return routes
|
|
122
|
-
|
|
123
|
-
const seg = (route.path || '').split('/').filter(Boolean)[0]
|
|
124
|
-
return seg === proto
|
|
125
|
-
}
|
|
126
|
-
const filter = (children) => children
|
|
127
|
-
.map((r) => {
|
|
128
|
-
if (r.path === '*') return r
|
|
129
|
-
if (!r.path) {
|
|
130
|
-
if (r.children) return { ...r, children: filter(r.children) }
|
|
131
|
-
return r
|
|
132
|
-
}
|
|
133
|
-
if (matchesProto(r)) {
|
|
134
|
-
return r.children ? { ...r, children: filter(r.children) } : r
|
|
135
|
-
}
|
|
136
|
-
return null
|
|
137
|
-
})
|
|
138
|
-
.filter(Boolean)
|
|
139
|
-
return [{ ...routes[0], children: filter(routes[0].children || []) }]
|
|
137
|
+
return [{ ...routes[0], children: filterChildrenForProto(routes[0].children || [], proto) }]
|
|
140
138
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure (dependency-free) route-tree filtering for the isolated prototypes
|
|
3
|
+
* iframe. Kept separate from `prototypeRoutes.jsx` so it can be unit-tested
|
|
4
|
+
* without pulling in that module's eager `import.meta.glob` chain.
|
|
5
|
+
*
|
|
6
|
+
* Filter a route tree's children down to a single prototype subtree:
|
|
7
|
+
* - `*` splat fallback is always kept.
|
|
8
|
+
* - Pathless layout wrappers are kept, descending into their children to
|
|
9
|
+
* reach the prototype routes.
|
|
10
|
+
* - Any route WITH a path is kept iff its first segment matches the
|
|
11
|
+
* requested proto. When it matches, its ENTIRE subtree is kept intact,
|
|
12
|
+
* because generouted nests sub-routes under the prototype's path node
|
|
13
|
+
* (e.g. `canvas-threats/threats` → a `threats` child of `canvas-threats`).
|
|
14
|
+
* Re-filtering those children by first segment would drop them since
|
|
15
|
+
* their own segment isn't the proto name — that was the nested-route bug.
|
|
16
|
+
*/
|
|
17
|
+
export function filterChildrenForProto(children, proto) {
|
|
18
|
+
const matchesProto = (route) => {
|
|
19
|
+
const seg = (route.path || '').split('/').filter(Boolean)[0]
|
|
20
|
+
return seg === proto
|
|
21
|
+
}
|
|
22
|
+
return children
|
|
23
|
+
.map((r) => {
|
|
24
|
+
if (r.path === '*') return r
|
|
25
|
+
if (!r.path) {
|
|
26
|
+
return r.children ? { ...r, children: filterChildrenForProto(r.children, proto) } : r
|
|
27
|
+
}
|
|
28
|
+
return matchesProto(r) ? r : null
|
|
29
|
+
})
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
}
|
|
@@ -552,7 +552,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
552
552
|
// Match the placeholder's themed background so any
|
|
553
553
|
// brief gap during navigation (refresh or src change)
|
|
554
554
|
// never flashes the browser's default white.
|
|
555
|
-
background: 'var(--color-background
|
|
555
|
+
background: 'var(--color-background)',
|
|
556
556
|
}}
|
|
557
557
|
title={`${prototypeTitle} prototype`}
|
|
558
558
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
|
@@ -917,6 +917,10 @@ function buildDiscoveryResult({ index, protoFolders, flowRoutes, canvasRoutes, c
|
|
|
917
917
|
const dirPath = path.dirname(absPath).replace(/\\/g, '/')
|
|
918
918
|
const canvasRelDir = dirPath.replace(/^.*?src\/canvas\//, '')
|
|
919
919
|
canvasMetaByDir[canvasRelDir] = parsed
|
|
920
|
+
// Drafts canvases derive their group with `drafts/` stripped, so also
|
|
921
|
+
// index the meta under the stripped dir so the group lookup matches.
|
|
922
|
+
const canvasRelDirPublic = canvasRelDir.split('/').filter(seg => seg !== 'drafts').join('/')
|
|
923
|
+
if (canvasRelDirPublic !== canvasRelDir) canvasMetaByDir[canvasRelDirPublic] = parsed
|
|
920
924
|
}
|
|
921
925
|
} catch { /* skip invalid meta files */ }
|
|
922
926
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
-
import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars, parseDataFile } from './data-plugin.js'
|
|
4
|
+
import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars, parseDataFile, buildDataDiscovery } from './data-plugin.js'
|
|
5
5
|
|
|
6
6
|
const RESOLVED_ID = '\0virtual:storyboard-data-index'
|
|
7
7
|
|
|
@@ -1243,3 +1243,33 @@ describe('parseDataFile — canvas path-based IDs', () => {
|
|
|
1243
1243
|
expect(result.group).toBeNull()
|
|
1244
1244
|
})
|
|
1245
1245
|
})
|
|
1246
|
+
|
|
1247
|
+
describe('buildDataDiscovery — drafts canvas-group meta', () => {
|
|
1248
|
+
function writePage(dir, name, title) {
|
|
1249
|
+
const evt = { event: 'canvas_created', title, timestamp: Date.now() }
|
|
1250
|
+
writeFileSync(path.join(dir, `${name}.canvas.jsonl`), JSON.stringify(evt) + '\n')
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
it('attaches .meta.json to a drafts/ canvas group (drafts-stripped key reconciliation)', () => {
|
|
1254
|
+
const groupDir = path.join(tmpDir, 'src', 'canvas', 'drafts', 'security-vision')
|
|
1255
|
+
mkdirSync(groupDir, { recursive: true })
|
|
1256
|
+
writePage(groupDir, '1-security-vision', 'Security Vision')
|
|
1257
|
+
writePage(groupDir, '2-confidence-scores', 'Confidence Scores')
|
|
1258
|
+
writeFileSync(
|
|
1259
|
+
path.join(groupDir, 'security-vision.meta.json'),
|
|
1260
|
+
JSON.stringify({
|
|
1261
|
+
title: 'Security Vision',
|
|
1262
|
+
pageOrder: ['security-vision/1-security-vision', 'security-vision/2-confidence-scores'],
|
|
1263
|
+
}),
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
const discovery = buildDataDiscovery(tmpDir, { includeDraft: true })
|
|
1267
|
+
const page = discovery.canvases['drafts/security-vision/1-security-vision']
|
|
1268
|
+
expect(page).toBeTruthy()
|
|
1269
|
+
expect(page._group).toBe('security-vision')
|
|
1270
|
+
// Before the fix this was null because the meta was keyed under
|
|
1271
|
+
// "drafts/security-vision" while the group lookup used "security-vision".
|
|
1272
|
+
expect(page._canvasMeta).toBeTruthy()
|
|
1273
|
+
expect(page._canvasMeta.title).toBe('Security Vision')
|
|
1274
|
+
})
|
|
1275
|
+
})
|