@dfosco/storyboard-core 2.0.0 → 2.2.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/modes.config.json +17 -0
- package/package.json +8 -3
- package/src/bodyClasses.js +11 -8
- package/src/bodyClasses.test.js +26 -12
- package/src/devtools.js +9 -8
- package/src/devtools.test.js +2 -2
- package/src/index.js +20 -5
- package/src/loader.js +116 -35
- package/src/loader.test.js +189 -48
- package/src/modes.js +170 -0
- package/src/modes.test.js +216 -0
- package/src/sceneDebug.js +17 -13
- package/src/sceneDebug.test.js +42 -29
- package/src/svelte-plugin-ui/__tests__/ToolbarShell.test.ts +77 -19
- package/src/svelte-plugin-ui/__tests__/designModes.test.ts +1 -1
- package/src/svelte-plugin-ui/components/ToolbarShell.svelte +59 -19
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +573 -0
- package/src/svelte-plugin-ui/index.ts +3 -0
- package/src/svelte-plugin-ui/stores/toolStore.ts +71 -0
- package/src/svelte-plugin-ui/stores/types.ts +22 -8
- package/src/{svelte-plugin-ui/plugins → ui}/design-modes.ts +9 -7
- package/src/ui/viewfinder.ts +58 -0
- package/src/viewfinder.js +100 -20
- package/src/viewfinder.test.js +64 -42
- package/src/workshop/features/createPage/server.js +8 -8
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Svelte store that wraps the core tool registry API.
|
|
3
|
+
*
|
|
4
|
+
* Provides a readable store whose value updates whenever:
|
|
5
|
+
* - The active mode changes (different tools for each mode)
|
|
6
|
+
* - Tool state or actions change (setToolState, setToolAction)
|
|
7
|
+
*
|
|
8
|
+
* Groups tools into { tools, devTools } for the toolbar to consume.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { writable, type Readable } from 'svelte/store'
|
|
12
|
+
import {
|
|
13
|
+
getCurrentMode,
|
|
14
|
+
getToolsForMode,
|
|
15
|
+
subscribeToMode,
|
|
16
|
+
subscribeToTools,
|
|
17
|
+
} from './types.js'
|
|
18
|
+
import type { ResolvedTool } from './types.js'
|
|
19
|
+
|
|
20
|
+
export interface ToolStoreState {
|
|
21
|
+
/** Tools in the 'tools' group for the current mode */
|
|
22
|
+
tools: ResolvedTool[]
|
|
23
|
+
/** Tools in the 'dev' group for the current mode */
|
|
24
|
+
devTools: ResolvedTool[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function snapshot(): ToolStoreState {
|
|
28
|
+
const mode = getCurrentMode()
|
|
29
|
+
const allTools = getToolsForMode(mode)
|
|
30
|
+
return {
|
|
31
|
+
tools: allTools.filter((t) => t.group === 'tools'),
|
|
32
|
+
devTools: allTools.filter((t) => t.group === 'dev'),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createToolStore(): Readable<ToolStoreState> {
|
|
37
|
+
const { subscribe: rawSubscribe, set } = writable<ToolStoreState>(snapshot())
|
|
38
|
+
|
|
39
|
+
const subscribe: Readable<ToolStoreState>['subscribe'] = (run, invalidate) => {
|
|
40
|
+
set(snapshot())
|
|
41
|
+
|
|
42
|
+
// Re-snapshot on mode changes OR tool state/action changes
|
|
43
|
+
const unsubMode = subscribeToMode(() => set(snapshot()))
|
|
44
|
+
const unsubTools = subscribeToTools(() => set(snapshot()))
|
|
45
|
+
|
|
46
|
+
const unsubStore = rawSubscribe(run, invalidate)
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
unsubStore()
|
|
50
|
+
unsubMode()
|
|
51
|
+
unsubTools()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { subscribe }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Readable Svelte store for the tool registry.
|
|
60
|
+
*
|
|
61
|
+
* ```svelte
|
|
62
|
+
* <script>
|
|
63
|
+
* import { toolState } from '../stores/toolStore.js'
|
|
64
|
+
* </script>
|
|
65
|
+
*
|
|
66
|
+
* {#each $toolState.tools as tool}
|
|
67
|
+
* <button onclick={tool.action} disabled={!tool.state.enabled}>{tool.label}</button>
|
|
68
|
+
* {/each}
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export const toolState: Readable<ToolStoreState> = createToolStore()
|
|
@@ -19,14 +19,11 @@ export {
|
|
|
19
19
|
on,
|
|
20
20
|
off,
|
|
21
21
|
emit,
|
|
22
|
+
getToolsForMode,
|
|
23
|
+
subscribeToTools,
|
|
24
|
+
getToolsSnapshot,
|
|
22
25
|
} from '../../modes.js'
|
|
23
26
|
|
|
24
|
-
export interface ModeToolConfig {
|
|
25
|
-
id: string
|
|
26
|
-
label: string
|
|
27
|
-
action: () => void
|
|
28
|
-
}
|
|
29
|
-
|
|
30
27
|
export interface ModeConfig {
|
|
31
28
|
name: string
|
|
32
29
|
label: string
|
|
@@ -34,6 +31,23 @@ export interface ModeConfig {
|
|
|
34
31
|
className?: string | string[]
|
|
35
32
|
onActivate?: (options?: Record<string, unknown>) => void
|
|
36
33
|
onDeactivate?: () => void
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ToolState {
|
|
37
|
+
enabled: boolean
|
|
38
|
+
active: boolean
|
|
39
|
+
busy: boolean
|
|
40
|
+
hidden: boolean
|
|
41
|
+
badge: string | number | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ResolvedTool {
|
|
45
|
+
id: string
|
|
46
|
+
label: string
|
|
47
|
+
group: 'tools' | 'dev'
|
|
48
|
+
icon: string | null
|
|
49
|
+
order: number
|
|
50
|
+
modes: string[]
|
|
51
|
+
state: ToolState
|
|
52
|
+
action: (() => void) | null
|
|
39
53
|
}
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Design-modes
|
|
2
|
+
* Design-modes UI mount entry point.
|
|
3
3
|
*
|
|
4
4
|
* Call mountDesignModesUI() once at app startup to render the ModeSwitch
|
|
5
5
|
* and ToolbarShell components. Framework-agnostic — works from any JS
|
|
6
|
-
* context (React
|
|
6
|
+
* context (React StoryboardProvider, vanilla JS, etc.).
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
|
-
* import { mountDesignModesUI } from '@dfosco/storyboard-core/
|
|
9
|
+
* import { mountDesignModesUI } from '@dfosco/storyboard-core/ui/design-modes'
|
|
10
10
|
* mountDesignModesUI() // mounts to document.body
|
|
11
11
|
* mountDesignModesUI(document.getElementById('my-container'))
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { mountSveltePlugin, type PluginHandle } from '../mount.js'
|
|
15
|
-
import ModeSwitch from '../components/ModeSwitch.svelte'
|
|
16
|
-
|
|
14
|
+
import { mountSveltePlugin, type PluginHandle } from '../svelte-plugin-ui/mount.js'
|
|
15
|
+
import ModeSwitch from '../svelte-plugin-ui/components/ModeSwitch.svelte'
|
|
16
|
+
// TODO: Re-enable after migrating devtools features into tool registry
|
|
17
|
+
// import ToolbarShell from '../svelte-plugin-ui/components/ToolbarShell.svelte'
|
|
17
18
|
|
|
18
19
|
let handles: PluginHandle[] = []
|
|
19
20
|
|
|
@@ -35,7 +36,8 @@ export function mountDesignModesUI(
|
|
|
35
36
|
|
|
36
37
|
handles.push(
|
|
37
38
|
mountSveltePlugin(target, ModeSwitch),
|
|
38
|
-
|
|
39
|
+
// TODO: Re-enable after migrating devtools features into tool registry
|
|
40
|
+
// mountSveltePlugin(target, ToolbarShell),
|
|
39
41
|
)
|
|
40
42
|
|
|
41
43
|
return unmountDesignModesUI
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewfinder UI mount entry point.
|
|
3
|
+
*
|
|
4
|
+
* Call mountViewfinder() to render the prototype index dashboard.
|
|
5
|
+
* Framework-agnostic — works from any JS context (React wrapper,
|
|
6
|
+
* vanilla JS, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { mountViewfinder } from '@dfosco/storyboard-core/ui/viewfinder'
|
|
10
|
+
* const handle = mountViewfinder(document.getElementById('root'), {
|
|
11
|
+
* title: 'My Storyboard',
|
|
12
|
+
* knownRoutes: ['Dashboard', 'Settings'],
|
|
13
|
+
* })
|
|
14
|
+
* // later: handle.destroy()
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mountSveltePlugin, type PluginHandle } from '../svelte-plugin-ui/mount.js'
|
|
18
|
+
import Viewfinder from '../svelte-plugin-ui/components/Viewfinder.svelte'
|
|
19
|
+
|
|
20
|
+
export interface ViewfinderProps {
|
|
21
|
+
title?: string
|
|
22
|
+
subtitle?: string
|
|
23
|
+
basePath?: string
|
|
24
|
+
knownRoutes?: string[]
|
|
25
|
+
showThumbnails?: boolean
|
|
26
|
+
hideDefaultFlow?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let handle: PluginHandle | null = null
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Mount the Viewfinder dashboard into a DOM container.
|
|
33
|
+
*
|
|
34
|
+
* Idempotent — calling multiple times is a no-op.
|
|
35
|
+
* Returns a handle with destroy() method.
|
|
36
|
+
*
|
|
37
|
+
* @param container - DOM element to mount into
|
|
38
|
+
* @param props - Viewfinder configuration
|
|
39
|
+
*/
|
|
40
|
+
export function mountViewfinder(
|
|
41
|
+
container: HTMLElement,
|
|
42
|
+
props?: ViewfinderProps,
|
|
43
|
+
): PluginHandle {
|
|
44
|
+
if (handle) return handle
|
|
45
|
+
|
|
46
|
+
handle = mountSveltePlugin(container, Viewfinder as any, props as any)
|
|
47
|
+
return handle
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Remove the Viewfinder from the DOM.
|
|
52
|
+
*/
|
|
53
|
+
export function unmountViewfinder(): void {
|
|
54
|
+
if (handle) {
|
|
55
|
+
handle.destroy()
|
|
56
|
+
handle = null
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/viewfinder.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { loadFlow, listFlows, listPrototypes, getPrototypeMetadata } from './loader.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Deterministic hash from a string — used for seeding generative placeholders.
|
|
@@ -14,52 +14,132 @@ export function hash(str) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Resolve the target route path for a
|
|
17
|
+
* Resolve the target route path for a flow.
|
|
18
18
|
*
|
|
19
|
-
* 1. If
|
|
20
|
-
* 2. If
|
|
19
|
+
* 1. If flow name matches a known route (case-insensitive), use that route
|
|
20
|
+
* 2. If flow data has a top-level `route`, or `meta.route` / `sceneMeta.route`, use that
|
|
21
21
|
* 3. Fall back to root "/"
|
|
22
22
|
*
|
|
23
|
-
* @param {string}
|
|
23
|
+
* @param {string} flowName
|
|
24
24
|
* @param {string[]} knownRoutes - Array of route names (e.g. ["Dashboard", "Repositories"])
|
|
25
|
-
* @returns {string} Full path with ?
|
|
25
|
+
* @returns {string} Full path with ?flow= param
|
|
26
26
|
*/
|
|
27
|
-
export function
|
|
27
|
+
export function resolveFlowRoute(flowName, knownRoutes = []) {
|
|
28
28
|
// Case-insensitive match against known routes
|
|
29
29
|
for (const route of knownRoutes) {
|
|
30
|
-
if (route.toLowerCase() ===
|
|
31
|
-
//
|
|
30
|
+
if (route.toLowerCase() === flowName.toLowerCase()) {
|
|
31
|
+
// Flow name matches the route — no ?flow= needed,
|
|
32
32
|
// StoryboardProvider auto-matches by page name
|
|
33
33
|
return `/${route}`
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
// Check for explicit route
|
|
37
|
+
// Check for explicit route: top-level `route`, then meta.route, then legacy sceneMeta.route
|
|
38
38
|
try {
|
|
39
|
-
const data =
|
|
40
|
-
const route = data?.
|
|
39
|
+
const data = loadFlow(flowName)
|
|
40
|
+
const route = data?.route || data?.meta?.route || data?.flowMeta?.route || data?.sceneMeta?.route
|
|
41
41
|
if (route) {
|
|
42
42
|
const normalized = route.startsWith('/') ? route : `/${route}`
|
|
43
|
-
return `${normalized}?
|
|
43
|
+
return `${normalized}?flow=${encodeURIComponent(flowName)}`
|
|
44
44
|
}
|
|
45
45
|
} catch {
|
|
46
46
|
// ignore load errors
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
return `/?
|
|
49
|
+
return `/?flow=${encodeURIComponent(flowName)}`
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** @deprecated Use resolveFlowRoute() */
|
|
53
|
+
export const resolveSceneRoute = resolveFlowRoute
|
|
54
|
+
|
|
52
55
|
/**
|
|
53
|
-
* Get
|
|
56
|
+
* Get meta for a flow (title, description, author, etc).
|
|
54
57
|
*
|
|
55
|
-
* @param {string}
|
|
56
|
-
* @returns {{
|
|
58
|
+
* @param {string} flowName
|
|
59
|
+
* @returns {{ title?: string, description?: string, author?: string | string[] } | null}
|
|
57
60
|
*/
|
|
58
|
-
export function
|
|
61
|
+
export function getFlowMeta(flowName) {
|
|
59
62
|
try {
|
|
60
|
-
const data =
|
|
61
|
-
return data?.sceneMeta || null
|
|
63
|
+
const data = loadFlow(flowName)
|
|
64
|
+
return data?.meta || data?.flowMeta || data?.sceneMeta || null
|
|
62
65
|
} catch {
|
|
63
66
|
return null
|
|
64
67
|
}
|
|
65
68
|
}
|
|
69
|
+
|
|
70
|
+
/** @deprecated Use getFlowMeta() */
|
|
71
|
+
export const getSceneMeta = getFlowMeta
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build a structured prototype index grouping flows by prototype.
|
|
75
|
+
*
|
|
76
|
+
* Returns an object with:
|
|
77
|
+
* - prototypes: array of prototype entries with metadata and their flows
|
|
78
|
+
* - globalFlows: flows not belonging to any prototype
|
|
79
|
+
*
|
|
80
|
+
* @param {string[]} [knownRoutes] - Array of known route names
|
|
81
|
+
* @returns {{ prototypes: Array, globalFlows: Array }}
|
|
82
|
+
*/
|
|
83
|
+
export function buildPrototypeIndex(knownRoutes = []) {
|
|
84
|
+
const flows = listFlows()
|
|
85
|
+
const protoMap = {}
|
|
86
|
+
const globalFlows = []
|
|
87
|
+
|
|
88
|
+
// Seed from .prototype.json metadata (even prototypes with no flows appear)
|
|
89
|
+
for (const name of listPrototypes()) {
|
|
90
|
+
const raw = getPrototypeMetadata(name)
|
|
91
|
+
const meta = raw?.meta || raw || {}
|
|
92
|
+
protoMap[name] = {
|
|
93
|
+
name: meta.title || name,
|
|
94
|
+
dirName: name,
|
|
95
|
+
description: meta.description || null,
|
|
96
|
+
author: meta.author || null,
|
|
97
|
+
gitAuthor: raw?.gitAuthor || null,
|
|
98
|
+
icon: meta.icon || null,
|
|
99
|
+
team: meta.team || null,
|
|
100
|
+
tags: meta.tags || null,
|
|
101
|
+
flows: [],
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const flowName of flows) {
|
|
106
|
+
const slashIdx = flowName.indexOf('/')
|
|
107
|
+
if (slashIdx > 0) {
|
|
108
|
+
const protoName = flowName.substring(0, slashIdx)
|
|
109
|
+
const shortName = flowName.substring(slashIdx + 1)
|
|
110
|
+
|
|
111
|
+
if (!protoMap[protoName]) {
|
|
112
|
+
protoMap[protoName] = {
|
|
113
|
+
name: protoName,
|
|
114
|
+
dirName: protoName,
|
|
115
|
+
description: null,
|
|
116
|
+
author: null,
|
|
117
|
+
gitAuthor: null,
|
|
118
|
+
icon: null,
|
|
119
|
+
team: null,
|
|
120
|
+
tags: null,
|
|
121
|
+
flows: [],
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
protoMap[protoName].flows.push({
|
|
126
|
+
key: flowName,
|
|
127
|
+
name: shortName,
|
|
128
|
+
route: resolveFlowRoute(flowName, knownRoutes),
|
|
129
|
+
meta: getFlowMeta(flowName),
|
|
130
|
+
})
|
|
131
|
+
} else {
|
|
132
|
+
globalFlows.push({
|
|
133
|
+
key: flowName,
|
|
134
|
+
name: flowName,
|
|
135
|
+
route: resolveFlowRoute(flowName, knownRoutes),
|
|
136
|
+
meta: getFlowMeta(flowName),
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
prototypes: Object.values(protoMap),
|
|
143
|
+
globalFlows,
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/viewfinder.test.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { init } from './loader.js'
|
|
2
|
-
import { hash, resolveSceneRoute, getSceneMeta } from './viewfinder.js'
|
|
2
|
+
import { hash, resolveFlowRoute, getFlowMeta, resolveSceneRoute, getSceneMeta } from './viewfinder.js'
|
|
3
3
|
|
|
4
4
|
const makeIndex = () => ({
|
|
5
|
-
|
|
5
|
+
flows: {
|
|
6
6
|
default: { title: 'Default Scene' },
|
|
7
7
|
Dashboard: { heading: 'Dashboard' },
|
|
8
8
|
'custom-route': { route: 'Overview', title: 'Custom' },
|
|
9
9
|
'absolute-route': { route: '/Forms', title: 'Absolute' },
|
|
10
10
|
'no-route': { title: 'No route key' },
|
|
11
|
-
'meta-route': {
|
|
12
|
-
'meta-author': {
|
|
13
|
-
'meta-authors': {
|
|
14
|
-
'meta-both': {
|
|
11
|
+
'meta-route': { flowMeta: { route: 'Repositories' }, title: 'Meta Route' },
|
|
12
|
+
'meta-author': { flowMeta: { author: 'dfosco' }, title: 'With Author' },
|
|
13
|
+
'meta-authors': { flowMeta: { author: ['dfosco', 'heyamie', 'branonconor'] }, title: 'Multi Author' },
|
|
14
|
+
'meta-both': { flowMeta: { route: '/Overview', author: 'octocat' }, title: 'Both' },
|
|
15
15
|
},
|
|
16
16
|
objects: {},
|
|
17
17
|
records: {},
|
|
@@ -41,90 +41,112 @@ describe('hash', () => {
|
|
|
41
41
|
})
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
-
describe('
|
|
44
|
+
describe('resolveFlowRoute', () => {
|
|
45
45
|
const routes = ['Dashboard', 'Overview', 'Forms', 'Repositories']
|
|
46
46
|
|
|
47
|
-
it('matches
|
|
48
|
-
expect(
|
|
47
|
+
it('matches flow name to route (exact case)', () => {
|
|
48
|
+
expect(resolveFlowRoute('Dashboard', routes)).toBe('/Dashboard')
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
it('matches
|
|
52
|
-
expect(
|
|
51
|
+
it('matches flow name to route (case-insensitive)', () => {
|
|
52
|
+
expect(resolveFlowRoute('dashboard', routes)).toBe('/Dashboard')
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
-
it('uses route key from
|
|
56
|
-
expect(
|
|
55
|
+
it('uses route key from flow data when no route matches', () => {
|
|
56
|
+
expect(resolveFlowRoute('custom-route', routes)).toBe('/Overview?flow=custom-route')
|
|
57
57
|
})
|
|
58
58
|
|
|
59
59
|
it('handles absolute route key (with leading slash)', () => {
|
|
60
|
-
expect(
|
|
60
|
+
expect(resolveFlowRoute('absolute-route', routes)).toBe('/Forms?flow=absolute-route')
|
|
61
61
|
})
|
|
62
62
|
|
|
63
63
|
it('falls back to root when no match and no route key', () => {
|
|
64
|
-
expect(
|
|
64
|
+
expect(resolveFlowRoute('no-route', routes)).toBe('/?flow=no-route')
|
|
65
65
|
})
|
|
66
66
|
|
|
67
|
-
it('falls back to root for default
|
|
68
|
-
expect(
|
|
67
|
+
it('falls back to root for default flow', () => {
|
|
68
|
+
expect(resolveFlowRoute('default', routes)).toBe('/?flow=default')
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
-
it('falls back to root when
|
|
72
|
-
expect(
|
|
71
|
+
it('falls back to root when flow does not exist', () => {
|
|
72
|
+
expect(resolveFlowRoute('nonexistent', routes)).toBe('/?flow=nonexistent')
|
|
73
73
|
})
|
|
74
74
|
|
|
75
75
|
it('works with empty routes array', () => {
|
|
76
|
-
expect(
|
|
76
|
+
expect(resolveFlowRoute('Dashboard', [])).toBe('/?flow=Dashboard')
|
|
77
77
|
})
|
|
78
78
|
|
|
79
79
|
it('works with no routes argument', () => {
|
|
80
|
-
expect(
|
|
80
|
+
expect(resolveFlowRoute('custom-route')).toBe('/Overview?flow=custom-route')
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
-
it('encodes special characters in
|
|
83
|
+
it('encodes special characters in flow name', () => {
|
|
84
84
|
init({
|
|
85
|
-
|
|
85
|
+
flows: { 'has spaces': { title: 'Spaces' } },
|
|
86
86
|
objects: {},
|
|
87
87
|
records: {},
|
|
88
88
|
})
|
|
89
|
-
expect(
|
|
89
|
+
expect(resolveFlowRoute('has spaces', [])).toBe('/?flow=has%20spaces')
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
-
it('uses
|
|
93
|
-
expect(
|
|
92
|
+
it('uses flowMeta.route when no route matches', () => {
|
|
93
|
+
expect(resolveFlowRoute('meta-route', routes)).toBe('/Repositories?flow=meta-route')
|
|
94
94
|
})
|
|
95
95
|
|
|
96
|
-
it('uses
|
|
97
|
-
expect(
|
|
96
|
+
it('uses flowMeta.route with absolute path', () => {
|
|
97
|
+
expect(resolveFlowRoute('meta-both', routes)).toBe('/Overview?flow=meta-both')
|
|
98
98
|
})
|
|
99
99
|
|
|
100
|
-
it('prefers
|
|
100
|
+
it('prefers top-level route over flowMeta.route', () => {
|
|
101
101
|
init({
|
|
102
|
-
|
|
102
|
+
flows: { conflict: { route: 'Forms', flowMeta: { route: 'Dashboard' } } },
|
|
103
103
|
objects: {},
|
|
104
104
|
records: {},
|
|
105
105
|
})
|
|
106
|
-
expect(
|
|
106
|
+
expect(resolveFlowRoute('conflict', [])).toBe('/Forms?flow=conflict')
|
|
107
107
|
})
|
|
108
108
|
})
|
|
109
109
|
|
|
110
|
-
describe('
|
|
111
|
-
it('returns
|
|
112
|
-
expect(
|
|
110
|
+
describe('getFlowMeta', () => {
|
|
111
|
+
it('returns flowMeta when present', () => {
|
|
112
|
+
expect(getFlowMeta('meta-author')).toEqual({ author: 'dfosco' })
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('returns flowMeta with both fields', () => {
|
|
116
|
+
expect(getFlowMeta('meta-both')).toEqual({ route: '/Overview', author: 'octocat' })
|
|
113
117
|
})
|
|
114
118
|
|
|
115
|
-
it('returns
|
|
116
|
-
expect(
|
|
119
|
+
it('returns flowMeta with array author', () => {
|
|
120
|
+
expect(getFlowMeta('meta-authors')).toEqual({ author: ['dfosco', 'heyamie', 'branonconor'] })
|
|
117
121
|
})
|
|
118
122
|
|
|
119
|
-
it('returns
|
|
120
|
-
expect(
|
|
123
|
+
it('returns null when no flowMeta', () => {
|
|
124
|
+
expect(getFlowMeta('default')).toBeNull()
|
|
121
125
|
})
|
|
122
126
|
|
|
123
|
-
it('returns null
|
|
124
|
-
expect(
|
|
127
|
+
it('returns null for nonexistent flow', () => {
|
|
128
|
+
expect(getFlowMeta('nonexistent')).toBeNull()
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// ── Deprecated aliases ──
|
|
133
|
+
|
|
134
|
+
describe('resolveSceneRoute (deprecated alias)', () => {
|
|
135
|
+
it('is the same function as resolveFlowRoute', () => {
|
|
136
|
+
expect(resolveSceneRoute).toBe(resolveFlowRoute)
|
|
125
137
|
})
|
|
126
138
|
|
|
127
|
-
it('
|
|
128
|
-
expect(
|
|
139
|
+
it('resolves a flow route', () => {
|
|
140
|
+
expect(resolveSceneRoute('Dashboard', ['Dashboard'])).toBe('/Dashboard')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('getSceneMeta (deprecated alias)', () => {
|
|
145
|
+
it('is the same function as getFlowMeta', () => {
|
|
146
|
+
expect(getSceneMeta).toBe(getFlowMeta)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('returns flow meta', () => {
|
|
150
|
+
expect(getSceneMeta('meta-author')).toEqual({ author: 'dfosco' })
|
|
129
151
|
})
|
|
130
152
|
})
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import fs from 'node:fs'
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const FLOW_SKELETON = JSON.stringify({ $global: [] }, null, 2) + '\n'
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Convert a raw name to PascalCase for use as component name + filename.
|
|
@@ -66,7 +66,7 @@ function renderTemplate(templatesDir, templateName, pageName) {
|
|
|
66
66
|
* List all existing page files in src/pages/.
|
|
67
67
|
*/
|
|
68
68
|
function listPages(root) {
|
|
69
|
-
const pagesDir = path.join(root, 'src', '
|
|
69
|
+
const pagesDir = path.join(root, 'src', 'prototypes')
|
|
70
70
|
if (!fs.existsSync(pagesDir)) return []
|
|
71
71
|
|
|
72
72
|
return fs.readdirSync(pagesDir)
|
|
@@ -103,7 +103,7 @@ export function createPagesHandler(ctx, templatesDir) {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
const { pascalName } = validation
|
|
106
|
-
const pagesDir = path.join(root, 'src', '
|
|
106
|
+
const pagesDir = path.join(root, 'src', 'prototypes')
|
|
107
107
|
const pagePath = path.join(pagesDir, `${pascalName}.jsx`)
|
|
108
108
|
|
|
109
109
|
if (fs.existsSync(pagePath)) {
|
|
@@ -124,18 +124,18 @@ export function createPagesHandler(ctx, templatesDir) {
|
|
|
124
124
|
|
|
125
125
|
const result = {
|
|
126
126
|
success: true,
|
|
127
|
-
path: `src/
|
|
127
|
+
path: `src/prototypes/${pascalName}.jsx`,
|
|
128
128
|
route: `/${pascalName}`,
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
if (createScene) {
|
|
132
132
|
const dataDir = path.join(root, 'src', 'data')
|
|
133
|
-
const
|
|
133
|
+
const flowPath = path.join(dataDir, `${pascalName}.flow.json`)
|
|
134
134
|
|
|
135
|
-
if (!fs.existsSync(
|
|
135
|
+
if (!fs.existsSync(flowPath)) {
|
|
136
136
|
fs.mkdirSync(dataDir, { recursive: true })
|
|
137
|
-
fs.writeFileSync(
|
|
138
|
-
result.
|
|
137
|
+
fs.writeFileSync(flowPath, FLOW_SKELETON, 'utf-8')
|
|
138
|
+
result.flowPath = `src/data/${pascalName}.flow.json`
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|