@dfosco/storyboard-core 2.2.0 → 2.4.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 -1
- package/src/index.js +2 -0
- package/src/loader.js +20 -2
- package/src/loader.test.js +39 -1
- package/src/svelte-plugin-ui/components/Octicon.svelte +75 -0
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +299 -70
- package/src/viewfinder.js +77 -5
- package/src/viewfinder.test.js +166 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"./svelte-plugin-ui/styles/base.css": "./src/svelte-plugin-ui/styles/base.css"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
+
"@primer/octicons": "^19.22.0",
|
|
31
32
|
"alpinejs": "^3.15.8",
|
|
32
33
|
"jsonc-parser": "^3.3.1",
|
|
33
34
|
"tachyons": "^4.12.0"
|
package/src/index.js
CHANGED
|
@@ -14,6 +14,8 @@ export { loadFlow, listFlows, flowExists, loadRecord, findRecord, loadObject, de
|
|
|
14
14
|
export { resolveFlowName, resolveRecordName } from './loader.js'
|
|
15
15
|
// Prototype metadata
|
|
16
16
|
export { listPrototypes, getPrototypeMetadata } from './loader.js'
|
|
17
|
+
// Folder metadata
|
|
18
|
+
export { listFolders, getFolderMetadata } from './loader.js'
|
|
17
19
|
// Deprecated scene aliases
|
|
18
20
|
export { loadScene, listScenes, sceneExists } from './loader.js'
|
|
19
21
|
|
package/src/loader.js
CHANGED
|
@@ -30,13 +30,13 @@ function deepMerge(target, source) {
|
|
|
30
30
|
* Module-level data index, seeded by init().
|
|
31
31
|
* Shape: { flows: {}, objects: {}, records: {} }
|
|
32
32
|
*/
|
|
33
|
-
let dataIndex = { flows: {}, objects: {}, records: {}, prototypes: {} }
|
|
33
|
+
let dataIndex = { flows: {}, objects: {}, records: {}, prototypes: {}, folders: {} }
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
36
|
* Seed the data index. Call once at app startup before any load functions.
|
|
37
37
|
* The Vite data plugin calls this automatically via the generated virtual module.
|
|
38
38
|
*
|
|
39
|
-
* @param {{ flows?: object, scenes?: object, objects: object, records: object, prototypes?: object }} index
|
|
39
|
+
* @param {{ flows?: object, scenes?: object, objects: object, records: object, prototypes?: object, folders?: object }} index
|
|
40
40
|
*/
|
|
41
41
|
export function init(index) {
|
|
42
42
|
if (!index || typeof index !== 'object') {
|
|
@@ -47,6 +47,7 @@ export function init(index) {
|
|
|
47
47
|
objects: index.objects || {},
|
|
48
48
|
records: index.records || {},
|
|
49
49
|
prototypes: index.prototypes || {},
|
|
50
|
+
folders: index.folders || {},
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -303,4 +304,21 @@ export function getPrototypeMetadata(name) {
|
|
|
303
304
|
return dataIndex.prototypes[name] ?? null
|
|
304
305
|
}
|
|
305
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Returns the names of all registered folders.
|
|
309
|
+
* @returns {string[]}
|
|
310
|
+
*/
|
|
311
|
+
export function listFolders() {
|
|
312
|
+
return Object.keys(dataIndex.folders)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Returns folder metadata by name.
|
|
317
|
+
* @param {string} name - Folder name (e.g. "Getting Started")
|
|
318
|
+
* @returns {object|null} Metadata from the .folder.json file, or null
|
|
319
|
+
*/
|
|
320
|
+
export function getFolderMetadata(name) {
|
|
321
|
+
return dataIndex.folders[name] ?? null
|
|
322
|
+
}
|
|
323
|
+
|
|
306
324
|
export { deepMerge }
|
package/src/loader.test.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { init, loadFlow, listFlows, flowExists, loadScene, listScenes, sceneExists, loadRecord, findRecord, loadObject, deepMerge, resolveFlowName, resolveRecordName } from './loader.js'
|
|
1
|
+
import { init, loadFlow, listFlows, flowExists, loadScene, listScenes, sceneExists, loadRecord, findRecord, loadObject, deepMerge, resolveFlowName, resolveRecordName, listFolders, getFolderMetadata } from './loader.js'
|
|
2
2
|
|
|
3
3
|
const makeIndex = () => ({
|
|
4
4
|
flows: {
|
|
@@ -416,3 +416,41 @@ describe('error hints for scoped data', () => {
|
|
|
416
416
|
expect(() => loadRecord('xyz')).not.toThrow(/Did you mean/)
|
|
417
417
|
})
|
|
418
418
|
})
|
|
419
|
+
|
|
420
|
+
// ── Folder functions ──
|
|
421
|
+
|
|
422
|
+
describe('listFolders', () => {
|
|
423
|
+
it('returns empty array when no folders registered', () => {
|
|
424
|
+
init(makeIndex())
|
|
425
|
+
expect(listFolders()).toEqual([])
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('returns folder names when folders are registered', () => {
|
|
429
|
+
init({
|
|
430
|
+
...makeIndex(),
|
|
431
|
+
folders: {
|
|
432
|
+
'Getting Started': { meta: { title: 'Getting Started' } },
|
|
433
|
+
Advanced: { meta: { title: 'Advanced' } },
|
|
434
|
+
},
|
|
435
|
+
})
|
|
436
|
+
expect(listFolders()).toEqual(['Getting Started', 'Advanced'])
|
|
437
|
+
})
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
describe('getFolderMetadata', () => {
|
|
441
|
+
it('returns null when folder does not exist', () => {
|
|
442
|
+
init(makeIndex())
|
|
443
|
+
expect(getFolderMetadata('nonexistent')).toBeNull()
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('returns folder metadata when folder exists', () => {
|
|
447
|
+
init({
|
|
448
|
+
...makeIndex(),
|
|
449
|
+
folders: {
|
|
450
|
+
'My Folder': { meta: { title: 'My Folder', description: 'A folder' } },
|
|
451
|
+
},
|
|
452
|
+
})
|
|
453
|
+
const meta = getFolderMetadata('My Folder')
|
|
454
|
+
expect(meta).toEqual({ meta: { title: 'My Folder', description: 'A folder' } })
|
|
455
|
+
})
|
|
456
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Octicon — renders a Primer Octicon by name.
|
|
3
|
+
|
|
4
|
+
Includes custom icon overrides (e.g. folder, folder-open) that replace
|
|
5
|
+
or extend the Primer set.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
<Octicon name="repo" />
|
|
9
|
+
<Octicon name="folder" color="#54aeff" />
|
|
10
|
+
<Octicon name="folder-open" size={24} />
|
|
11
|
+
<Octicon name="gear" size={16} label="Settings" />
|
|
12
|
+
<Octicon name="lock" offsetX={1} offsetY={-1} />
|
|
13
|
+
-->
|
|
14
|
+
|
|
15
|
+
<script lang="ts">
|
|
16
|
+
import octicons from '@primer/octicons'
|
|
17
|
+
|
|
18
|
+
// Custom SVG paths that override or extend the Primer icon set.
|
|
19
|
+
// Each entry: viewBox string + path d attribute.
|
|
20
|
+
const customIcons: Record<string, { viewBox: string; path: string }> = {
|
|
21
|
+
'folder': {
|
|
22
|
+
viewBox: '0 0 24 24',
|
|
23
|
+
path: 'M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h5.175q.4 0 .763.15t.637.425L12 6h8q.825 0 1.413.588T22 8v10q0 .825-.587 1.413T20 20z',
|
|
24
|
+
},
|
|
25
|
+
'folder-open': {
|
|
26
|
+
viewBox: '0 0 24 24',
|
|
27
|
+
path: 'M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h5.175q.4 0 .763.15t.637.425L12 6h9q.425 0 .713.288T22 7t-.288.713T21 8H7.85q-1.55 0-2.7.975T4 11.45V18l1.975-6.575q.2-.65.738-1.037T7.9 10h12.9q1.025 0 1.613.813t.312 1.762l-1.8 6q-.2.65-.737 1.038T19 20z',
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
name: string
|
|
33
|
+
size?: number
|
|
34
|
+
label?: string
|
|
35
|
+
color?: string
|
|
36
|
+
offsetX?: number
|
|
37
|
+
offsetY?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let { name, size = 16, label, color, offsetX = 0, offsetY = 0 }: Props = $props()
|
|
41
|
+
|
|
42
|
+
const ariaAttrs = $derived(
|
|
43
|
+
label ? `aria-label="${label}"` : 'aria-hidden="true"'
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const custom = $derived(customIcons[name])
|
|
47
|
+
|
|
48
|
+
const svg = $derived(
|
|
49
|
+
custom
|
|
50
|
+
? `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="${custom.viewBox}" fill="currentColor" ${ariaAttrs}><path d="${custom.path}"/></svg>`
|
|
51
|
+
: octicons[name]?.toSVG({
|
|
52
|
+
width: size,
|
|
53
|
+
height: size,
|
|
54
|
+
...(label ? { 'aria-label': label } : { 'aria-hidden': 'true' }),
|
|
55
|
+
}) ?? ''
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const style = $derived(
|
|
59
|
+
[
|
|
60
|
+
color ? `color: ${color}` : '',
|
|
61
|
+
color ? `fill: ${color}` : '',
|
|
62
|
+
(offsetX || offsetY) ? `translate: ${offsetX}px ${offsetY}px` : '',
|
|
63
|
+
].filter(Boolean).join('; ') || undefined
|
|
64
|
+
)
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<span class="octicon" {style}>{@html svg}</span>
|
|
68
|
+
|
|
69
|
+
<style>
|
|
70
|
+
.octicon {
|
|
71
|
+
display: inline-flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
vertical-align: middle;
|
|
74
|
+
}
|
|
75
|
+
</style>
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
<script lang="ts">
|
|
12
12
|
import { buildPrototypeIndex } from '../../viewfinder.js'
|
|
13
|
+
import Octicon from './Octicon.svelte'
|
|
13
14
|
|
|
14
15
|
interface Props {
|
|
15
16
|
title?: string
|
|
@@ -37,39 +38,54 @@
|
|
|
37
38
|
: prototypeIndex.globalFlows
|
|
38
39
|
)
|
|
39
40
|
|
|
40
|
-
//
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
:
|
|
41
|
+
// Build a flat display list: folders (with nested prototypes), ungrouped prototypes, global flows
|
|
42
|
+
const ungroupedProtos = $derived(prototypeIndex.prototypes)
|
|
43
|
+
|
|
44
|
+
const folders = $derived(prototypeIndex.folders || [])
|
|
45
|
+
|
|
46
|
+
const otherFlows = $derived.by(() => {
|
|
47
|
+
if (globalFlows.length === 0) return null
|
|
48
|
+
return {
|
|
49
|
+
name: 'Other flows',
|
|
50
|
+
dirName: '__global__',
|
|
51
|
+
description: null,
|
|
52
|
+
author: null,
|
|
53
|
+
gitAuthor: null,
|
|
54
|
+
lastModified: null,
|
|
55
|
+
icon: null,
|
|
56
|
+
team: null,
|
|
57
|
+
tags: null,
|
|
58
|
+
flows: globalFlows,
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const totalProtos = $derived(
|
|
63
|
+
ungroupedProtos.length + folders.reduce((sum: number, f: any) => sum + f.prototypes.length, 0)
|
|
58
64
|
)
|
|
59
65
|
|
|
60
66
|
const totalFlows = $derived(
|
|
61
|
-
|
|
67
|
+
ungroupedProtos.reduce((sum: number, p: any) => sum + p.flows.length, 0) +
|
|
68
|
+
globalFlows.length +
|
|
69
|
+
folders.reduce((sum: number, f: any) =>
|
|
70
|
+
sum + f.prototypes.reduce((s: number, p: any) => s + p.flows.length, 0), 0)
|
|
62
71
|
)
|
|
63
72
|
|
|
64
|
-
//
|
|
73
|
+
// Sorting — use pre-sorted arrays from buildPrototypeIndex
|
|
74
|
+
type SortMode = 'updated' | 'title'
|
|
75
|
+
let sortBy: SortMode = $state('updated')
|
|
76
|
+
|
|
77
|
+
const sortedProtos = $derived(prototypeIndex.sorted?.[sortBy]?.prototypes ?? ungroupedProtos)
|
|
78
|
+
const sortedFolders = $derived(prototypeIndex.sorted?.[sortBy]?.folders ?? folders)
|
|
79
|
+
|
|
80
|
+
// Expanded state — all prototypes and folders start expanded
|
|
65
81
|
let expanded: Record<string, boolean> = $state({})
|
|
66
82
|
|
|
67
83
|
function isExpanded(dirName: string): boolean {
|
|
68
84
|
return expanded[dirName] ?? true
|
|
69
85
|
}
|
|
70
86
|
|
|
71
|
-
function
|
|
72
|
-
expanded[dirName] = !
|
|
87
|
+
function toggle(dirName: string) {
|
|
88
|
+
expanded[dirName] = !isExpanded(dirName)
|
|
73
89
|
}
|
|
74
90
|
|
|
75
91
|
function protoRoute(dirName: string): string {
|
|
@@ -165,11 +181,32 @@
|
|
|
165
181
|
<p class="subtitle">{subtitle}</p>
|
|
166
182
|
{/if}
|
|
167
183
|
</div>
|
|
184
|
+
</div>
|
|
185
|
+
<div class="controlsRow">
|
|
186
|
+
<!-- <span class="sceneCount">
|
|
187
|
+
{(folders.length > 0 ? `${folders.length} folder${folders.length !== 1 ? 's' : ''} · ` : '') + `${totalProtos} prototype${totalProtos !== 1 ? 's' : ''} · ${totalFlows} flow${totalFlows !== 1 ? 's' : ''}`}
|
|
188
|
+
</span> -->
|
|
189
|
+
<div class="sortToggle">
|
|
190
|
+
<button
|
|
191
|
+
class="sortButton"
|
|
192
|
+
class:sortButtonActive={sortBy === 'updated'}
|
|
193
|
+
onclick={() => sortBy = 'updated'}
|
|
194
|
+
>
|
|
195
|
+
<Octicon name="clock" size={14} offsetY={-1} />
|
|
196
|
+
Last updated
|
|
197
|
+
</button>
|
|
198
|
+
<button
|
|
199
|
+
class="sortButton"
|
|
200
|
+
class:sortButtonActive={sortBy === 'title'}
|
|
201
|
+
onclick={() => sortBy = 'title'}
|
|
202
|
+
>
|
|
203
|
+
<Octicon name="sort-asc" size={14} offsetY={-1} />
|
|
204
|
+
Title A–Z
|
|
205
|
+
</button>
|
|
206
|
+
</div>
|
|
168
207
|
{#if branches && branches.length > 0}
|
|
169
208
|
<div class="branchDropdown">
|
|
170
|
-
<
|
|
171
|
-
<path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.492 2.492 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
|
|
172
|
-
</svg>
|
|
209
|
+
<span class="branchIcon"><Octicon size={16} color="var(--fgColor-muted)" offsetY={-1} offsetX={2} name="git-branch" /></span>
|
|
173
210
|
<select
|
|
174
211
|
class="branchSelect"
|
|
175
212
|
onchange={handleBranchChange}
|
|
@@ -183,32 +220,61 @@
|
|
|
183
220
|
</div>
|
|
184
221
|
{/if}
|
|
185
222
|
</div>
|
|
186
|
-
<p class="sceneCount">
|
|
187
|
-
{allGroups.length} prototype{allGroups.length !== 1 ? 's' : ''} · {totalFlows} flow{totalFlows !== 1 ? 's' : ''}
|
|
188
|
-
</p>
|
|
189
223
|
</header>
|
|
190
224
|
|
|
191
|
-
{#if
|
|
225
|
+
{#if totalProtos === 0 && folders.length === 0}
|
|
192
226
|
<p class="empty">No flows found. Add a <code>*.flow.json</code> file to get started.</p>
|
|
193
227
|
{:else}
|
|
194
228
|
<div class="list">
|
|
195
|
-
{#
|
|
229
|
+
{#snippet protoEntry(proto)}
|
|
196
230
|
<section class="protoGroup">
|
|
197
|
-
{#if proto.flows.length
|
|
231
|
+
{#if proto.hideFlows && proto.flows.length === 1}
|
|
232
|
+
<!-- Single flow, hidden — navigates directly to the flow -->
|
|
233
|
+
<a class="listItem" href={proto.flows[0].route}>
|
|
234
|
+
<div class="cardBody">
|
|
235
|
+
<p class="protoName" class:otherflows={proto.dirName === '__global__'}>
|
|
236
|
+
{#if proto.icon}<span class="protoIcon">{proto.icon}</span>{/if}
|
|
237
|
+
{proto.name}
|
|
238
|
+
</p>
|
|
239
|
+
{#if proto.description}
|
|
240
|
+
<p class="protoDesc">{proto.description}</p>
|
|
241
|
+
{/if}
|
|
242
|
+
{#if proto.author}
|
|
243
|
+
{@const authors = Array.isArray(proto.author) ? proto.author : [proto.author]}
|
|
244
|
+
<div class="author">
|
|
245
|
+
<span class="authorAvatars">
|
|
246
|
+
{#each authors as a (a)}
|
|
247
|
+
<img
|
|
248
|
+
src="https://github.com/{a}.png?size=48"
|
|
249
|
+
alt={a}
|
|
250
|
+
class="authorAvatar"
|
|
251
|
+
/>
|
|
252
|
+
{/each}
|
|
253
|
+
</span>
|
|
254
|
+
<span class="authorName">{authors.join(', ')}</span>
|
|
255
|
+
</div>
|
|
256
|
+
{:else if proto.gitAuthor}
|
|
257
|
+
<p class="authorPlain">{proto.gitAuthor}</p>
|
|
258
|
+
{/if}
|
|
259
|
+
</div>
|
|
260
|
+
</a>
|
|
261
|
+
{:else if proto.flows.length > 0}
|
|
198
262
|
<!-- Expandable prototype with flows -->
|
|
199
263
|
<button
|
|
200
264
|
class="listItem protoHeader"
|
|
201
|
-
onclick={() =>
|
|
265
|
+
onclick={() => toggle(proto.dirName)}
|
|
202
266
|
aria-expanded={isExpanded(proto.dirName)}
|
|
203
267
|
>
|
|
204
268
|
<div class="cardBody">
|
|
205
|
-
<p class="
|
|
269
|
+
<p class="protoName" class:otherflows={proto.dirName === '__global__'}>
|
|
206
270
|
{#if proto.icon}<span class="protoIcon">{proto.icon}</span>{/if}
|
|
207
271
|
{proto.name}
|
|
208
|
-
<span class="protoChevron"
|
|
209
|
-
|
|
210
|
-
<
|
|
211
|
-
|
|
272
|
+
<span class="protoChevron">
|
|
273
|
+
{#if isExpanded(proto.dirName)}
|
|
274
|
+
<Octicon size={12} color="var(--fgColor-disabled)" name="chevron-down" offsetY={-3} offsetX={2} />
|
|
275
|
+
{:else}
|
|
276
|
+
<Octicon size={12} color="var(--fgColor-disabled)" name="chevron-right" offsetY={-3} offsetX={2} />
|
|
277
|
+
{/if}
|
|
212
278
|
</span>
|
|
213
279
|
</p>
|
|
214
280
|
{#if proto.description}
|
|
@@ -237,7 +303,7 @@
|
|
|
237
303
|
<!-- Prototype with no flows — navigates directly -->
|
|
238
304
|
<a class="listItem" href={protoRoute(proto.dirName)}>
|
|
239
305
|
<div class="cardBody">
|
|
240
|
-
<p class="
|
|
306
|
+
<p class="protoName" class:otherflows={proto.dirName === '__global__'}>
|
|
241
307
|
{#if proto.icon}<span class="protoIcon">{proto.icon}</span>{/if}
|
|
242
308
|
{proto.name}
|
|
243
309
|
</p>
|
|
@@ -265,7 +331,7 @@
|
|
|
265
331
|
</a>
|
|
266
332
|
{/if}
|
|
267
333
|
|
|
268
|
-
{#if isExpanded(proto.dirName) && proto.flows.length > 0}
|
|
334
|
+
{#if !(proto.hideFlows && proto.flows.length === 1) && isExpanded(proto.dirName) && proto.flows.length > 0}
|
|
269
335
|
<div class="flowList">
|
|
270
336
|
{#each proto.flows as flow (flow.key)}
|
|
271
337
|
<a href={flow.route} class="listItem flowItem">
|
|
@@ -275,7 +341,7 @@
|
|
|
275
341
|
</div>
|
|
276
342
|
{/if}
|
|
277
343
|
<div class="cardBody">
|
|
278
|
-
<p class="
|
|
344
|
+
<p class="protoName">{flow.meta?.title || formatName(flow.name)}</p>
|
|
279
345
|
{#if flow.meta?.description}
|
|
280
346
|
<p class="flowDesc">{flow.meta.description}</p>
|
|
281
347
|
{/if}
|
|
@@ -285,7 +351,49 @@
|
|
|
285
351
|
</div>
|
|
286
352
|
{/if}
|
|
287
353
|
</section>
|
|
354
|
+
{/snippet}
|
|
355
|
+
|
|
356
|
+
<!-- Folders with their prototypes -->
|
|
357
|
+
{#each sortedFolders as folder (folder.dirName)}
|
|
358
|
+
<section class="folderGroup" class:folderGroupOpen={isExpanded(`folder:${folder.dirName}`)}>
|
|
359
|
+
<button
|
|
360
|
+
class="folderHeader"
|
|
361
|
+
onclick={() => toggle(`folder:${folder.dirName}`)}
|
|
362
|
+
aria-expanded={isExpanded(`folder:${folder.dirName}`)}
|
|
363
|
+
>
|
|
364
|
+
<p class="folderName">
|
|
365
|
+
<span>
|
|
366
|
+
{#if isExpanded(`folder:${folder.dirName}`)}
|
|
367
|
+
<Octicon size={20} offsetY={-1.5} name="folder-open" color="#54aeff" />
|
|
368
|
+
{:else}
|
|
369
|
+
<Octicon size={20} offsetY={-1.5} name="folder" color="#54aeff" />
|
|
370
|
+
{/if}
|
|
371
|
+
</span>
|
|
372
|
+
{folder.name}
|
|
373
|
+
</p>
|
|
374
|
+
{#if folder.description}
|
|
375
|
+
<p class="folderDesc">{folder.description}</p>
|
|
376
|
+
{/if}
|
|
377
|
+
</button>
|
|
378
|
+
{#if isExpanded(`folder:${folder.dirName}`) && folder.prototypes.length > 0}
|
|
379
|
+
<div class="folderContent">
|
|
380
|
+
{#each folder.prototypes as proto (proto.dirName)}
|
|
381
|
+
{@render protoEntry(proto)}
|
|
382
|
+
{/each}
|
|
383
|
+
</div>
|
|
384
|
+
{/if}
|
|
385
|
+
</section>
|
|
386
|
+
{/each}
|
|
387
|
+
|
|
388
|
+
<!-- Ungrouped prototypes (not in any folder) -->
|
|
389
|
+
{#each sortedProtos as proto (proto.dirName)}
|
|
390
|
+
{@render protoEntry(proto)}
|
|
288
391
|
{/each}
|
|
392
|
+
|
|
393
|
+
<!-- Other flows (always at the bottom) -->
|
|
394
|
+
{#if otherFlows}
|
|
395
|
+
{@render protoEntry(otherFlows)}
|
|
396
|
+
{/if}
|
|
289
397
|
</div>
|
|
290
398
|
{/if}
|
|
291
399
|
</div>
|
|
@@ -300,19 +408,17 @@
|
|
|
300
408
|
|
|
301
409
|
.header {
|
|
302
410
|
max-width: 720px;
|
|
303
|
-
margin: 0 auto
|
|
411
|
+
margin: 0 auto 40px;
|
|
304
412
|
}
|
|
305
413
|
|
|
306
414
|
.headerTop {
|
|
307
415
|
display: flex;
|
|
308
416
|
align-items: baseline;
|
|
309
|
-
justify-content: space-between;
|
|
310
417
|
gap: 16px;
|
|
311
418
|
}
|
|
312
419
|
|
|
313
420
|
.title {
|
|
314
|
-
font
|
|
315
|
-
font-weight: 400;
|
|
421
|
+
font: var(--text-display-shorthand);
|
|
316
422
|
margin: 0 0 12px;
|
|
317
423
|
color: var(--fgColor-default, #e6edf3);
|
|
318
424
|
letter-spacing: -0.03em;
|
|
@@ -326,11 +432,64 @@
|
|
|
326
432
|
letter-spacing: 0.01em;
|
|
327
433
|
}
|
|
328
434
|
|
|
329
|
-
.
|
|
435
|
+
.controlsRow {
|
|
436
|
+
display: flex;
|
|
437
|
+
align-items: center;
|
|
438
|
+
gap: 8px;
|
|
439
|
+
margin: 16px 0 0;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/* .sceneCount {
|
|
330
443
|
font-size: 13px;
|
|
331
444
|
color: var(--fgColor-muted, #848d97);
|
|
332
|
-
margin: 16px 0 0;
|
|
333
445
|
letter-spacing: 0.01em;
|
|
446
|
+
white-space: nowrap;
|
|
447
|
+
} */
|
|
448
|
+
|
|
449
|
+
.sortToggle {
|
|
450
|
+
display: flex;
|
|
451
|
+
gap: 2px;
|
|
452
|
+
background: var(--bgColor-inset);
|
|
453
|
+
padding: var(--base-size-4) var(--base-size-6);
|
|
454
|
+
border-radius: 9999px;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
.sortButton {
|
|
458
|
+
display: inline-flex;
|
|
459
|
+
align-items: center;
|
|
460
|
+
border-radius: 9999px;
|
|
461
|
+
gap: 4px;
|
|
462
|
+
padding: 6px 10px;
|
|
463
|
+
font-size: 12px;
|
|
464
|
+
font-family: inherit;
|
|
465
|
+
color: var(--fgColor-muted, #848d97);
|
|
466
|
+
background: transparent;
|
|
467
|
+
border: none;
|
|
468
|
+
cursor: pointer;
|
|
469
|
+
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
|
470
|
+
|
|
471
|
+
&:first-child {
|
|
472
|
+
transform: translateX(-1px);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
&:last-child {
|
|
476
|
+
transform: translateX(1px);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
.sortButton:hover {
|
|
481
|
+
color: var(--fgColor-default, #e6edf3);
|
|
482
|
+
background: var(--bgColor-neutral-muted, rgba(110, 118, 129, 0.1));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
.sortButtonActive {
|
|
486
|
+
color: var(--fgColor-default, #e6edf3);
|
|
487
|
+
background: var(--bgColor-neutral-muted, rgba(110, 118, 129, 0.15));
|
|
488
|
+
border: none;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.sortButton:first-child {
|
|
492
|
+
transform: translateX(-1px);
|
|
334
493
|
}
|
|
335
494
|
|
|
336
495
|
.branchDropdown {
|
|
@@ -339,6 +498,7 @@
|
|
|
339
498
|
gap: 0;
|
|
340
499
|
flex-shrink: 0;
|
|
341
500
|
position: relative;
|
|
501
|
+
margin-left: auto;
|
|
342
502
|
}
|
|
343
503
|
|
|
344
504
|
.branchIcon {
|
|
@@ -370,7 +530,7 @@
|
|
|
370
530
|
}
|
|
371
531
|
|
|
372
532
|
.branchSelect:hover {
|
|
373
|
-
border-color:
|
|
533
|
+
border-color: #bbbbbb;
|
|
374
534
|
}
|
|
375
535
|
|
|
376
536
|
.branchSelect:focus-visible {
|
|
@@ -381,6 +541,7 @@
|
|
|
381
541
|
.list {
|
|
382
542
|
display: flex;
|
|
383
543
|
flex-direction: column;
|
|
544
|
+
gap: var(--base-size-8);
|
|
384
545
|
max-width: 720px;
|
|
385
546
|
margin: 0 auto;
|
|
386
547
|
}
|
|
@@ -388,11 +549,79 @@
|
|
|
388
549
|
.protoGroup {
|
|
389
550
|
display: flex;
|
|
390
551
|
flex-direction: column;
|
|
552
|
+
gap: var(--base-size-8);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
.protoGroup > .listItem {
|
|
556
|
+
border: 1px solid var(--borderColor-muted, #30363d);
|
|
557
|
+
border-radius: var(--base-size-6);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
.folderGroup {
|
|
561
|
+
display: flex;
|
|
562
|
+
flex-direction: column;
|
|
563
|
+
gap: var(--base-size-8);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
.folderHeader {
|
|
567
|
+
display: flex;
|
|
568
|
+
flex-direction: row;
|
|
569
|
+
align-items: baseline;
|
|
570
|
+
justify-content: flex-start;
|
|
571
|
+
gap: var(--base-size-8);
|
|
572
|
+
appearance: none;
|
|
573
|
+
border: none;
|
|
574
|
+
border-radius: var(--base-size-6);
|
|
575
|
+
border: 1px solid var(--borderColor-muted, #30363d);
|
|
576
|
+
background: none;
|
|
577
|
+
width: 100%;
|
|
578
|
+
text-align: left;
|
|
579
|
+
cursor: pointer;
|
|
580
|
+
color: inherit;
|
|
581
|
+
padding: var(--base-size-16);
|
|
582
|
+
|
|
583
|
+
&:hover,
|
|
584
|
+
.folderGroupOpen & {
|
|
585
|
+
background-color: var(--bgColor-muted, #161b22);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
.folderGroupOpen .folderHeader {
|
|
591
|
+
background-color: var(--bgColor-muted, #161b22);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
.folderName {
|
|
596
|
+
display: inline-flex;
|
|
597
|
+
align-items: center;
|
|
598
|
+
gap: var(--base-size-8);
|
|
599
|
+
font-size: var(--text-body-size-small);
|
|
600
|
+
font-weight: 600;
|
|
601
|
+
color: var(--fgColor-default);
|
|
602
|
+
margin: 0;
|
|
603
|
+
letter-spacing: 0.04em;
|
|
604
|
+
text-transform: uppercase;
|
|
605
|
+
line-height: 1.6;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.folderDesc {
|
|
609
|
+
font-size: var(--text-body-size-small);
|
|
610
|
+
color: var(--fgColor-muted, #848d97);
|
|
611
|
+
margin: 0;
|
|
612
|
+
letter-spacing: 0.01em;
|
|
613
|
+
text-transform: none;
|
|
614
|
+
font-weight: 400;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
.folderContent {
|
|
618
|
+
display: flex;
|
|
619
|
+
flex-direction: column;
|
|
620
|
+
gap: var(--base-size-8);
|
|
391
621
|
}
|
|
392
622
|
|
|
393
623
|
.listItem {
|
|
394
624
|
display: block;
|
|
395
|
-
padding: 8px 0;
|
|
396
625
|
text-decoration: none;
|
|
397
626
|
color: inherit;
|
|
398
627
|
}
|
|
@@ -405,11 +634,17 @@
|
|
|
405
634
|
appearance: none;
|
|
406
635
|
border: none;
|
|
407
636
|
background: none;
|
|
637
|
+
border-radius: var(--base-size-6);
|
|
408
638
|
width: 100%;
|
|
409
639
|
text-align: left;
|
|
410
640
|
cursor: pointer;
|
|
411
641
|
color: inherit;
|
|
412
|
-
padding:
|
|
642
|
+
padding: 0;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
.protoHeader[aria-expanded="true"] .cardBody {
|
|
646
|
+
background-color: var(--bgColor-muted);
|
|
647
|
+
border-radius: var(--base-size-6);
|
|
413
648
|
}
|
|
414
649
|
|
|
415
650
|
.cardBody {
|
|
@@ -417,11 +652,11 @@
|
|
|
417
652
|
}
|
|
418
653
|
|
|
419
654
|
.cardBody:hover {
|
|
420
|
-
background-color: var(--bgColor-muted
|
|
421
|
-
border-radius:
|
|
655
|
+
background-color: var(--bgColor-muted);
|
|
656
|
+
border-radius: var(--base-size-6);
|
|
422
657
|
}
|
|
423
658
|
|
|
424
|
-
.
|
|
659
|
+
.protoName {
|
|
425
660
|
font-size: var(--text-title-size-medium);
|
|
426
661
|
font-weight: 400;
|
|
427
662
|
color: var(--fgColor-default, #e6edf3);
|
|
@@ -430,23 +665,17 @@
|
|
|
430
665
|
line-height: 1.6;
|
|
431
666
|
transition: font-style 0.15s ease;
|
|
432
667
|
}
|
|
668
|
+
|
|
669
|
+
.protoName.otherflows {
|
|
670
|
+
font-size: var(--text-body-size-small);
|
|
671
|
+
font-weight: 600;
|
|
672
|
+
text-transform: uppercase;
|
|
673
|
+
direction: rtl;
|
|
433
674
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
color: var(--fgColor-muted, #848d97);
|
|
438
|
-
transition: transform 0.15s ease;
|
|
439
|
-
transform: rotate(0deg);
|
|
440
|
-
margin-right: 4px;
|
|
441
|
-
vertical-align: middle;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
.protoChevronOpen {
|
|
445
|
-
transform: rotate(90deg);
|
|
446
|
-
}
|
|
675
|
+
& .protoChevron {
|
|
676
|
+
margin-right: var(--base-size-8);
|
|
677
|
+
}
|
|
447
678
|
|
|
448
|
-
.protoIcon {
|
|
449
|
-
margin-right: 4px;
|
|
450
679
|
}
|
|
451
680
|
|
|
452
681
|
.protoDesc {
|
|
@@ -501,14 +730,14 @@
|
|
|
501
730
|
}
|
|
502
731
|
|
|
503
732
|
.flowList {
|
|
504
|
-
margin: 0
|
|
733
|
+
margin: 0;
|
|
505
734
|
padding: 0;
|
|
506
735
|
display: flex;
|
|
507
736
|
flex-direction: column;
|
|
508
737
|
}
|
|
509
738
|
|
|
510
739
|
.flowItem {
|
|
511
|
-
border: 1px solid var(--borderColor-muted
|
|
740
|
+
border: 1px solid var(--borderColor-muted);
|
|
512
741
|
padding: 0;
|
|
513
742
|
}
|
|
514
743
|
|
|
@@ -530,7 +759,7 @@
|
|
|
530
759
|
border-radius: var(--base-size-6);
|
|
531
760
|
}
|
|
532
761
|
|
|
533
|
-
.flowItem .
|
|
762
|
+
.flowItem .protoName {
|
|
534
763
|
font-size: var(--text-title-size-small);
|
|
535
764
|
color: var(--fgColor-muted);
|
|
536
765
|
}
|
package/src/viewfinder.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadFlow, listFlows, listPrototypes, getPrototypeMetadata } from './loader.js'
|
|
1
|
+
import { loadFlow, listFlows, listPrototypes, getPrototypeMetadata, listFolders, getFolderMetadata } from './loader.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Deterministic hash from a string — used for seeding generative placeholders.
|
|
@@ -71,14 +71,16 @@ export function getFlowMeta(flowName) {
|
|
|
71
71
|
export const getSceneMeta = getFlowMeta
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
|
-
* Build a structured prototype index grouping flows by prototype
|
|
74
|
+
* Build a structured prototype index grouping flows by prototype,
|
|
75
|
+
* and prototypes by folder.
|
|
75
76
|
*
|
|
76
77
|
* Returns an object with:
|
|
77
|
-
* -
|
|
78
|
+
* - folders: array of folder entries containing their prototypes
|
|
79
|
+
* - prototypes: array of ungrouped prototype entries (not in any folder)
|
|
78
80
|
* - globalFlows: flows not belonging to any prototype
|
|
79
81
|
*
|
|
80
82
|
* @param {string[]} [knownRoutes] - Array of known route names
|
|
81
|
-
* @returns {{ prototypes: Array, globalFlows: Array }}
|
|
83
|
+
* @returns {{ folders: Array, prototypes: Array, globalFlows: Array }}
|
|
82
84
|
*/
|
|
83
85
|
export function buildPrototypeIndex(knownRoutes = []) {
|
|
84
86
|
const flows = listFlows()
|
|
@@ -95,9 +97,12 @@ export function buildPrototypeIndex(knownRoutes = []) {
|
|
|
95
97
|
description: meta.description || null,
|
|
96
98
|
author: meta.author || null,
|
|
97
99
|
gitAuthor: raw?.gitAuthor || null,
|
|
100
|
+
lastModified: raw?.lastModified || null,
|
|
98
101
|
icon: meta.icon || null,
|
|
99
102
|
team: meta.team || null,
|
|
100
103
|
tags: meta.tags || null,
|
|
104
|
+
hideFlows: meta.hideFlows ?? raw?.hideFlows ?? false,
|
|
105
|
+
folder: raw?.folder || null,
|
|
101
106
|
flows: [],
|
|
102
107
|
}
|
|
103
108
|
}
|
|
@@ -115,9 +120,12 @@ export function buildPrototypeIndex(knownRoutes = []) {
|
|
|
115
120
|
description: null,
|
|
116
121
|
author: null,
|
|
117
122
|
gitAuthor: null,
|
|
123
|
+
lastModified: null,
|
|
118
124
|
icon: null,
|
|
119
125
|
team: null,
|
|
120
126
|
tags: null,
|
|
127
|
+
hideFlows: false,
|
|
128
|
+
folder: null,
|
|
121
129
|
flows: [],
|
|
122
130
|
}
|
|
123
131
|
}
|
|
@@ -138,8 +146,72 @@ export function buildPrototypeIndex(knownRoutes = []) {
|
|
|
138
146
|
}
|
|
139
147
|
}
|
|
140
148
|
|
|
149
|
+
// Build folder entries from .folder.json metadata
|
|
150
|
+
const folderMap = {}
|
|
151
|
+
for (const folderName of listFolders()) {
|
|
152
|
+
const raw = getFolderMetadata(folderName)
|
|
153
|
+
const meta = raw?.meta || raw || {}
|
|
154
|
+
folderMap[folderName] = {
|
|
155
|
+
name: meta.title || folderName,
|
|
156
|
+
dirName: folderName,
|
|
157
|
+
description: meta.description || null,
|
|
158
|
+
icon: meta.icon || null,
|
|
159
|
+
prototypes: [],
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Partition prototypes into folders vs ungrouped
|
|
164
|
+
const ungrouped = []
|
|
165
|
+
for (const proto of Object.values(protoMap)) {
|
|
166
|
+
if (proto.folder && folderMap[proto.folder]) {
|
|
167
|
+
folderMap[proto.folder].prototypes.push(proto)
|
|
168
|
+
} else if (proto.folder) {
|
|
169
|
+
// Folder referenced but no .folder.json — create an implicit folder
|
|
170
|
+
folderMap[proto.folder] = {
|
|
171
|
+
name: proto.folder,
|
|
172
|
+
dirName: proto.folder,
|
|
173
|
+
description: null,
|
|
174
|
+
icon: null,
|
|
175
|
+
prototypes: [proto],
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
ungrouped.push(proto)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const folders = Object.values(folderMap)
|
|
183
|
+
const prototypes = ungrouped
|
|
184
|
+
|
|
185
|
+
// Pre-sort by title (A-Z)
|
|
186
|
+
const sortByTitle = (a, b) => (a.name || '').localeCompare(b.name || '')
|
|
187
|
+
|
|
188
|
+
// Pre-sort by last updated (newest first, nulls last)
|
|
189
|
+
const sortByUpdated = (a, b) => {
|
|
190
|
+
const aTime = a.lastModified ? new Date(a.lastModified).getTime() : 0
|
|
191
|
+
const bTime = b.lastModified ? new Date(b.lastModified).getTime() : 0
|
|
192
|
+
return bTime - aTime
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Sort folder contents by their most recently updated prototype
|
|
196
|
+
const folderByUpdated = (a, b) => {
|
|
197
|
+
const aMax = Math.max(0, ...a.prototypes.map(p => p.lastModified ? new Date(p.lastModified).getTime() : 0))
|
|
198
|
+
const bMax = Math.max(0, ...b.prototypes.map(p => p.lastModified ? new Date(p.lastModified).getTime() : 0))
|
|
199
|
+
return bMax - aMax
|
|
200
|
+
}
|
|
201
|
+
|
|
141
202
|
return {
|
|
142
|
-
|
|
203
|
+
folders,
|
|
204
|
+
prototypes,
|
|
143
205
|
globalFlows,
|
|
206
|
+
sorted: {
|
|
207
|
+
title: {
|
|
208
|
+
prototypes: [...prototypes].sort(sortByTitle),
|
|
209
|
+
folders: [...folders].map(f => ({ ...f, prototypes: [...f.prototypes].sort(sortByTitle) })).sort(sortByTitle),
|
|
210
|
+
},
|
|
211
|
+
updated: {
|
|
212
|
+
prototypes: [...prototypes].sort(sortByUpdated),
|
|
213
|
+
folders: [...folders].map(f => ({ ...f, prototypes: [...f.prototypes].sort(sortByUpdated) })).sort(folderByUpdated),
|
|
214
|
+
},
|
|
215
|
+
},
|
|
144
216
|
}
|
|
145
217
|
}
|
package/src/viewfinder.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { init } from './loader.js'
|
|
2
|
-
import { hash, resolveFlowRoute, getFlowMeta, resolveSceneRoute, getSceneMeta } from './viewfinder.js'
|
|
2
|
+
import { hash, resolveFlowRoute, getFlowMeta, resolveSceneRoute, getSceneMeta, buildPrototypeIndex } from './viewfinder.js'
|
|
3
3
|
|
|
4
4
|
const makeIndex = () => ({
|
|
5
5
|
flows: {
|
|
@@ -150,3 +150,168 @@ describe('getSceneMeta (deprecated alias)', () => {
|
|
|
150
150
|
expect(getSceneMeta('meta-author')).toEqual({ author: 'dfosco' })
|
|
151
151
|
})
|
|
152
152
|
})
|
|
153
|
+
|
|
154
|
+
// ── buildPrototypeIndex ──
|
|
155
|
+
|
|
156
|
+
describe('buildPrototypeIndex', () => {
|
|
157
|
+
it('passes hideFlows from prototype metadata', () => {
|
|
158
|
+
init({
|
|
159
|
+
flows: { 'MyProto/only-flow': { meta: { title: 'Only Flow' } } },
|
|
160
|
+
objects: {},
|
|
161
|
+
records: {},
|
|
162
|
+
prototypes: {
|
|
163
|
+
MyProto: { meta: { title: 'My Proto', hideFlows: true } },
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
const { prototypes } = buildPrototypeIndex([])
|
|
167
|
+
const proto = prototypes.find(p => p.dirName === 'MyProto')
|
|
168
|
+
expect(proto.hideFlows).toBe(true)
|
|
169
|
+
expect(proto.flows).toHaveLength(1)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('defaults hideFlows to false when not set', () => {
|
|
173
|
+
init({
|
|
174
|
+
flows: { 'Other/flow-a': { meta: { title: 'A' } } },
|
|
175
|
+
objects: {},
|
|
176
|
+
records: {},
|
|
177
|
+
prototypes: {
|
|
178
|
+
Other: { meta: { title: 'Other Proto' } },
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
const { prototypes } = buildPrototypeIndex([])
|
|
182
|
+
const proto = prototypes.find(p => p.dirName === 'Other')
|
|
183
|
+
expect(proto.hideFlows).toBe(false)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('reads hideFlows from top-level prototype metadata (outside meta key)', () => {
|
|
187
|
+
init({
|
|
188
|
+
flows: { 'TopLevel/only-flow': { meta: { title: 'Only Flow' } } },
|
|
189
|
+
objects: {},
|
|
190
|
+
records: {},
|
|
191
|
+
prototypes: {
|
|
192
|
+
TopLevel: { meta: { title: 'Top Level' }, hideFlows: true },
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
const { prototypes } = buildPrototypeIndex([])
|
|
196
|
+
const proto = prototypes.find(p => p.dirName === 'TopLevel')
|
|
197
|
+
expect(proto.hideFlows).toBe(true)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('groups prototypes into folders when folder field is set', () => {
|
|
201
|
+
init({
|
|
202
|
+
flows: {
|
|
203
|
+
'Example/basic': { meta: { title: 'Basic' } },
|
|
204
|
+
'Signup/default': { meta: { title: 'Default' } },
|
|
205
|
+
},
|
|
206
|
+
objects: {},
|
|
207
|
+
records: {},
|
|
208
|
+
prototypes: {
|
|
209
|
+
Example: { meta: { title: 'Examples' }, folder: 'Getting Started' },
|
|
210
|
+
Signup: { meta: { title: 'Sign Up' }, folder: 'Getting Started' },
|
|
211
|
+
},
|
|
212
|
+
folders: {
|
|
213
|
+
'Getting Started': { meta: { title: 'Getting Started', description: 'Intro prototypes', icon: '📚' } },
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
const result = buildPrototypeIndex([])
|
|
217
|
+
expect(result.folders).toHaveLength(1)
|
|
218
|
+
expect(result.prototypes).toHaveLength(0)
|
|
219
|
+
|
|
220
|
+
const folder = result.folders[0]
|
|
221
|
+
expect(folder.name).toBe('Getting Started')
|
|
222
|
+
expect(folder.description).toBe('Intro prototypes')
|
|
223
|
+
expect(folder.icon).toBe('📚')
|
|
224
|
+
expect(folder.prototypes).toHaveLength(2)
|
|
225
|
+
expect(folder.prototypes.map(p => p.dirName)).toContain('Example')
|
|
226
|
+
expect(folder.prototypes.map(p => p.dirName)).toContain('Signup')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('keeps prototypes without a folder as ungrouped', () => {
|
|
230
|
+
init({
|
|
231
|
+
flows: {
|
|
232
|
+
'Grouped/flow-a': {},
|
|
233
|
+
'Standalone/flow-b': {},
|
|
234
|
+
},
|
|
235
|
+
objects: {},
|
|
236
|
+
records: {},
|
|
237
|
+
prototypes: {
|
|
238
|
+
Grouped: { meta: { title: 'Grouped' }, folder: 'MyFolder' },
|
|
239
|
+
Standalone: { meta: { title: 'Standalone' } },
|
|
240
|
+
},
|
|
241
|
+
folders: {
|
|
242
|
+
MyFolder: { meta: { title: 'My Folder' } },
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
const result = buildPrototypeIndex([])
|
|
246
|
+
expect(result.folders).toHaveLength(1)
|
|
247
|
+
expect(result.prototypes).toHaveLength(1)
|
|
248
|
+
expect(result.prototypes[0].dirName).toBe('Standalone')
|
|
249
|
+
expect(result.folders[0].prototypes).toHaveLength(1)
|
|
250
|
+
expect(result.folders[0].prototypes[0].dirName).toBe('Grouped')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('creates implicit folder when prototype references a folder with no metadata', () => {
|
|
254
|
+
init({
|
|
255
|
+
flows: { 'Proto/flow': {} },
|
|
256
|
+
objects: {},
|
|
257
|
+
records: {},
|
|
258
|
+
prototypes: {
|
|
259
|
+
Proto: { meta: { title: 'Proto' }, folder: 'Implicit' },
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
const result = buildPrototypeIndex([])
|
|
263
|
+
expect(result.folders).toHaveLength(1)
|
|
264
|
+
expect(result.folders[0].name).toBe('Implicit')
|
|
265
|
+
expect(result.folders[0].prototypes).toHaveLength(1)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('uses folder directory name as display name when no title in metadata', () => {
|
|
269
|
+
init({
|
|
270
|
+
flows: {},
|
|
271
|
+
objects: {},
|
|
272
|
+
records: {},
|
|
273
|
+
prototypes: {},
|
|
274
|
+
folders: {
|
|
275
|
+
'My Folder': {},
|
|
276
|
+
},
|
|
277
|
+
})
|
|
278
|
+
const result = buildPrototypeIndex([])
|
|
279
|
+
expect(result.folders).toHaveLength(1)
|
|
280
|
+
expect(result.folders[0].name).toBe('My Folder')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('returns empty folders array when no folders exist', () => {
|
|
284
|
+
init({
|
|
285
|
+
flows: { 'A/flow': {} },
|
|
286
|
+
objects: {},
|
|
287
|
+
records: {},
|
|
288
|
+
prototypes: { A: { meta: { title: 'A' } } },
|
|
289
|
+
})
|
|
290
|
+
const result = buildPrototypeIndex([])
|
|
291
|
+
expect(result.folders).toHaveLength(0)
|
|
292
|
+
expect(result.prototypes).toHaveLength(1)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('passes through lastModified from prototype metadata', () => {
|
|
296
|
+
const ts = '2025-01-15T10:30:00-05:00'
|
|
297
|
+
init({
|
|
298
|
+
flows: { 'App/home': {} },
|
|
299
|
+
objects: {},
|
|
300
|
+
records: {},
|
|
301
|
+
prototypes: { App: { meta: { title: 'My App' }, lastModified: ts } },
|
|
302
|
+
})
|
|
303
|
+
const result = buildPrototypeIndex([])
|
|
304
|
+
expect(result.prototypes[0].lastModified).toBe(ts)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('defaults lastModified to null when not provided', () => {
|
|
308
|
+
init({
|
|
309
|
+
flows: { 'App/home': {} },
|
|
310
|
+
objects: {},
|
|
311
|
+
records: {},
|
|
312
|
+
prototypes: { App: { meta: { title: 'My App' } } },
|
|
313
|
+
})
|
|
314
|
+
const result = buildPrototypeIndex([])
|
|
315
|
+
expect(result.prototypes[0].lastModified).toBeNull()
|
|
316
|
+
})
|
|
317
|
+
})
|