|
| 1 | +import type { DevframeJsonRenderSpec, JsonRenderView } from '@devframes/json-render' |
| 2 | +import type { DevframeNodeContext } from 'devframe/types' |
| 3 | +import { createJsonRenderView } from '@devframes/json-render/node' |
| 4 | +import { defineRpcFunction } from 'devframe' |
| 5 | +import { DEPLOY_ACTION, REFRESH_ACTION, SAVE_ACTION, VIEW_ID } from './shared.ts' |
| 6 | + |
| 7 | +const VITE_CONFIG = `import { defineConfig } from 'vite' |
| 8 | +
|
| 9 | +export default defineConfig({ |
| 10 | + base: './', |
| 11 | + build: { outDir: 'dist/client' }, |
| 12 | +}) |
| 13 | +` |
| 14 | + |
| 15 | +// Initial state model. Every value the spec reads via \`{ $state: '/…' }\` |
| 16 | +// resolves from here and updates live as the server patches it. |
| 17 | +export const dashboardState = { |
| 18 | + project: { name: 'acme-app', version: '1.4.2', license: 'MIT', repository: 'github.com/acme/app' }, |
| 19 | + metrics: { coverage: 82, bundle: 64 }, |
| 20 | + modules: [ |
| 21 | + { name: '@acme/core', size: '42 kB', status: 'ok' }, |
| 22 | + { name: '@acme/ui', size: '88 kB', status: 'ok' }, |
| 23 | + { name: '@acme/cli', size: '17 kB', status: 'stale' }, |
| 24 | + ], |
| 25 | + deps: { |
| 26 | + runtime: { vue: '^3.5', birpc: '^4.0' }, |
| 27 | + dev: { vite: '^8.1', tsdown: '^0.22', vitest: '^4.1' }, |
| 28 | + }, |
| 29 | + form: { name: '', darkMode: true, notifications: false }, |
| 30 | + building: false, |
| 31 | + uptime: 0, |
| 32 | +} |
| 33 | + |
| 34 | +/** |
| 35 | + * A server-authored spec exercising every base-catalog component. Values marked |
| 36 | + * `{ $state: '/…' }` resolve from live state; `{ $bindState: '/…' }` are |
| 37 | + * two-way inputs that write back into it. |
| 38 | + */ |
| 39 | +export const dashboardSpec: DevframeJsonRenderSpec = { |
| 40 | + root: 'root', |
| 41 | + elements: { |
| 42 | + root: { type: 'Stack', props: { gap: 16 }, children: ['header', 'overview', 'settings', 'modules', 'config', 'tree', 'footerDivider', 'footer'] }, |
| 43 | + |
| 44 | + // ── Header ──────────────────────────────────────────────────────────── |
| 45 | + header: { type: 'Stack', props: { direction: 'row', gap: 10, align: 'center', justify: 'between' }, children: ['headLeft', 'headRight'] }, |
| 46 | + headLeft: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['logo', 'headTitle'] }, |
| 47 | + logo: { type: 'Icon', props: { name: 'ph:cube-duotone', size: 26 }, children: [] }, |
| 48 | + headTitle: { type: 'Text', props: { text: 'Acme Dashboard', variant: 'heading' }, children: [] }, |
| 49 | + headRight: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['status', 'refreshBtn', 'deployBtn'] }, |
| 50 | + status: { type: 'Badge', props: { text: 'healthy', variant: 'success' }, children: [] }, |
| 51 | + refreshBtn: { |
| 52 | + type: 'Button', |
| 53 | + props: { label: 'Refresh', variant: 'ghost', icon: 'ph:arrow-clockwise' }, |
| 54 | + on: { press: { action: REFRESH_ACTION } }, |
| 55 | + children: [], |
| 56 | + }, |
| 57 | + deployBtn: { |
| 58 | + type: 'Button', |
| 59 | + props: { label: 'Deploy', variant: 'primary', icon: 'ph:rocket-launch' }, |
| 60 | + on: { press: { action: DEPLOY_ACTION } }, |
| 61 | + children: [], |
| 62 | + }, |
| 63 | + |
| 64 | + // ── Overview: KeyValueTable + Progress ──────────────────────────────── |
| 65 | + overview: { type: 'Card', props: { title: 'Overview' }, children: ['overviewBody'] }, |
| 66 | + overviewBody: { type: 'Stack', props: { gap: 14 }, children: ['meta', 'coverage', 'bundle'] }, |
| 67 | + meta: { type: 'KeyValueTable', props: { data: { $state: '/project' } }, children: [] }, |
| 68 | + coverage: { type: 'Progress', props: { label: 'Test coverage', value: { $state: '/metrics/coverage' }, max: 100 }, children: [] }, |
| 69 | + bundle: { type: 'Progress', props: { label: 'Bundle budget', value: { $state: '/metrics/bundle' }, max: 100 }, children: [] }, |
| 70 | + |
| 71 | + // ── Settings: TextInput + Switch + Divider + Button ─────────────────── |
| 72 | + settings: { type: 'Card', props: { title: 'Settings', collapsible: true }, children: ['settingsBody'] }, |
| 73 | + settingsBody: { type: 'Stack', props: { gap: 10 }, children: ['nameInput', 'greeting', 'prefsDivider', 'darkSwitch', 'notifSwitch', 'saveBtn'] }, |
| 74 | + nameInput: { type: 'TextInput', props: { label: 'Display name', placeholder: 'Type your name…', value: { $bindState: '/form/name' } }, children: [] }, |
| 75 | + greeting: { type: 'Text', props: { text: { $state: '/form/name' }, variant: 'caption', color: 'primary' }, children: [] }, |
| 76 | + prefsDivider: { type: 'Divider', props: { label: 'preferences' }, children: [] }, |
| 77 | + darkSwitch: { type: 'Switch', props: { label: 'Dark mode', value: { $bindState: '/form/darkMode' } }, children: [] }, |
| 78 | + notifSwitch: { type: 'Switch', props: { label: 'Email notifications', value: { $bindState: '/form/notifications' } }, children: [] }, |
| 79 | + saveBtn: { |
| 80 | + type: 'Button', |
| 81 | + props: { label: 'Save settings', variant: 'secondary', icon: 'ph:floppy-disk' }, |
| 82 | + // Bound values are resolved into the action params before dispatch, so the |
| 83 | + // server receives whatever the user typed/toggled. |
| 84 | + on: { press: { action: SAVE_ACTION, params: { name: { $state: '/form/name' } } } }, |
| 85 | + children: [], |
| 86 | + }, |
| 87 | + |
| 88 | + // ── Modules: DataTable (loading bound to /building) ─────────────────── |
| 89 | + modules: { type: 'Card', props: { title: 'Modules' }, children: ['modulesTable'] }, |
| 90 | + modulesTable: { |
| 91 | + type: 'DataTable', |
| 92 | + props: { |
| 93 | + columns: [ |
| 94 | + { key: 'name', label: 'Module' }, |
| 95 | + { key: 'size', label: 'Size' }, |
| 96 | + { key: 'status', label: 'Status' }, |
| 97 | + ], |
| 98 | + rows: { $state: '/modules' }, |
| 99 | + height: 180, |
| 100 | + loading: { $state: '/building' }, |
| 101 | + }, |
| 102 | + children: [], |
| 103 | + }, |
| 104 | + |
| 105 | + // ── Config: CodeBlock ───────────────────────────────────────────────── |
| 106 | + config: { type: 'Card', props: { title: 'Config' }, children: ['code'] }, |
| 107 | + code: { type: 'CodeBlock', props: { filename: 'vite.config.ts', language: 'ts', code: VITE_CONFIG }, children: [] }, |
| 108 | + |
| 109 | + // ── Dependency tree: Tree ───────────────────────────────────────────── |
| 110 | + tree: { type: 'Card', props: { title: 'Dependency tree', collapsible: true, defaultCollapsed: true }, children: ['depTree'] }, |
| 111 | + depTree: { type: 'Tree', props: { data: { $state: '/deps' }, defaultExpanded: true }, children: [] }, |
| 112 | + |
| 113 | + // ── Footer ──────────────────────────────────────────────────────────── |
| 114 | + footerDivider: { type: 'Divider', props: {}, children: [] }, |
| 115 | + footer: { type: 'Stack', props: { direction: 'row', gap: 6 }, children: ['footerLabel', 'footerValue', 'footerSuffix'] }, |
| 116 | + footerLabel: { type: 'Text', props: { text: 'Rendered from a JSON-render spec · uptime', variant: 'caption', color: 'faint' }, children: [] }, |
| 117 | + footerValue: { type: 'Text', props: { text: { $state: '/uptime' }, variant: 'caption', color: 'faint' }, children: [] }, |
| 118 | + footerSuffix: { type: 'Text', props: { text: 's', variant: 'caption', color: 'faint' }, children: [] }, |
| 119 | + }, |
| 120 | + state: dashboardState, |
| 121 | +} |
| 122 | + |
| 123 | +/** |
| 124 | + * Create the dashboard JSON-render view on a devframe context and register the |
| 125 | + * actions its spec dispatches. Shared by the standalone devframe and the hub |
| 126 | + * shells (which additionally project it onto a `json-render` dock). Returns the |
| 127 | + * view handle so callers can build a dock ref from it. |
| 128 | + */ |
| 129 | +export function createDashboardView(ctx: DevframeNodeContext): JsonRenderView { |
| 130 | + const view = createJsonRenderView(ctx, { id: VIEW_ID, spec: dashboardSpec }) |
| 131 | + |
| 132 | + // The action bridge dispatches a spec action as an RPC call of the same |
| 133 | + // name. `Refresh` re-samples the metrics. |
| 134 | + ctx.rpc.register(defineRpcFunction({ |
| 135 | + name: REFRESH_ACTION, |
| 136 | + type: 'query', |
| 137 | + jsonSerializable: true, |
| 138 | + handler: () => { |
| 139 | + const coverage = 70 + Math.floor(Math.random() * 30) |
| 140 | + const bundle = 40 + Math.floor(Math.random() * 55) |
| 141 | + view.patchState([ |
| 142 | + { op: 'replace', path: '/metrics/coverage', value: coverage }, |
| 143 | + { op: 'replace', path: '/metrics/bundle', value: bundle }, |
| 144 | + ]) |
| 145 | + return { coverage, bundle } |
| 146 | + }, |
| 147 | + })) |
| 148 | + |
| 149 | + // `Deploy` flips the DataTable into a loading state, then appends a module — |
| 150 | + // demonstrating the per-action loading + a live spec/state change. |
| 151 | + let deployed = 0 |
| 152 | + ctx.rpc.register(defineRpcFunction({ |
| 153 | + name: DEPLOY_ACTION, |
| 154 | + type: 'query', |
| 155 | + jsonSerializable: true, |
| 156 | + handler: () => { |
| 157 | + view.patchState([{ op: 'replace', path: '/building', value: true }]) |
| 158 | + setTimeout(() => { |
| 159 | + deployed += 1 |
| 160 | + const modules = [ |
| 161 | + ...(view.value().state?.modules as unknown[] ?? []), |
| 162 | + { name: `@acme/edge-${deployed}`, size: '9 kB', status: 'ok' }, |
| 163 | + ] |
| 164 | + view.patchState([ |
| 165 | + { op: 'replace', path: '/modules', value: modules }, |
| 166 | + { op: 'replace', path: '/building', value: false }, |
| 167 | + ]) |
| 168 | + }, 1200) |
| 169 | + return { building: true } |
| 170 | + }, |
| 171 | + })) |
| 172 | + |
| 173 | + // `Save` receives the bound form values as params (resolved client-side from |
| 174 | + // `$state`) and writes the display name into the project metadata. |
| 175 | + ctx.rpc.register(defineRpcFunction({ |
| 176 | + name: SAVE_ACTION, |
| 177 | + type: 'query', |
| 178 | + jsonSerializable: true, |
| 179 | + handler: (params?: { name?: string }) => { |
| 180 | + const name = params?.name?.trim() |
| 181 | + if (name) |
| 182 | + view.patchState([{ op: 'replace', path: '/project/name', value: name }]) |
| 183 | + return { saved: true } |
| 184 | + }, |
| 185 | + })) |
| 186 | + |
| 187 | + // Live, server-driven state: tick uptime every second in dev. Unref the |
| 188 | + // timer so it never keeps a one-shot `build` run alive. |
| 189 | + if (ctx.mode === 'dev') { |
| 190 | + let uptime = 0 |
| 191 | + const timer = setInterval(() => { |
| 192 | + uptime += 1 |
| 193 | + view.patchState([{ op: 'replace', path: '/uptime', value: uptime }]) |
| 194 | + }, 1000) |
| 195 | + timer.unref?.() |
| 196 | + } |
| 197 | + |
| 198 | + return view |
| 199 | +} |
0 commit comments