@specverse/engines 4.2.1 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/inference/core/specly-converter.d.ts.map +1 -1
  2. package/dist/inference/core/specly-converter.js +43 -22
  3. package/dist/inference/core/specly-converter.js.map +1 -1
  4. package/dist/inference/index.d.ts.map +1 -1
  5. package/dist/inference/index.js +29 -0
  6. package/dist/inference/index.js.map +1 -1
  7. package/dist/libs/instance-factories/applications/templates/react/index-html-generator.js +2 -2
  8. package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +291 -59
  9. package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +37 -0
  10. package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +9 -124
  11. package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +9 -75
  12. package/dist/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.js +129 -0
  13. package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +9 -212
  14. package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +260 -5
  15. package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +8 -50
  16. package/dist/libs/instance-factories/applications/templates/react-starter/operation-emitters.js +470 -0
  17. package/dist/libs/instance-factories/applications/templates/react-starter/operation-view-generator.js +136 -0
  18. package/dist/libs/instance-factories/applications/templates/react-starter/package-json-generator.js +2 -1
  19. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  20. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  21. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  22. package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +49 -10
  23. package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +223 -10
  24. package/dist/libs/instance-factories/applications/templates/react-starter/views-generator.js +14 -1
  25. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +13 -1
  26. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +18 -0
  27. package/libs/instance-factories/applications/templates/react/index-html-generator.ts +2 -2
  28. package/libs/instance-factories/applications/templates/react-starter/__tests__/dashboard-body-composer.test.ts +3 -1
  29. package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +24 -25
  30. package/libs/instance-factories/applications/templates/react-starter/__tests__/list-body-composer.test.ts +3 -3
  31. package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +2 -0
  32. package/libs/instance-factories/applications/templates/react-starter/__tests__/parity-p3-rendered-output.test.ts +1 -1
  33. package/libs/instance-factories/applications/templates/react-starter/__tests__/starter-generators.test.ts +5 -4
  34. package/libs/instance-factories/applications/templates/react-starter/__tests__/views-generator.test.ts +11 -4
  35. package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +377 -71
  36. package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +66 -0
  37. package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +15 -182
  38. package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +16 -128
  39. package/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.ts +233 -0
  40. package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +16 -376
  41. package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +282 -4
  42. package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +26 -135
  43. package/libs/instance-factories/applications/templates/react-starter/operation-emitters.ts +497 -0
  44. package/libs/instance-factories/applications/templates/react-starter/operation-view-generator.ts +209 -0
  45. package/libs/instance-factories/applications/templates/react-starter/package-json-generator.ts +1 -0
  46. package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  47. package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  48. package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  49. package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +71 -10
  50. package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +359 -18
  51. package/libs/instance-factories/applications/templates/react-starter/views-generator.ts +28 -1
  52. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +13 -1
  53. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +18 -0
  54. package/package.json +2 -1
@@ -1,19 +1,60 @@
1
+ import { collectOperationViews } from "./operation-view-generator.js";
2
+ const VIEW_TYPES = ["list", "detail", "form", "dashboard"];
3
+ function viewSuffix(v) {
4
+ return v.charAt(0).toUpperCase() + v.slice(1) + "View";
5
+ }
1
6
  async function generate(context) {
2
7
  const models = Object.keys(context.spec.models ?? {});
3
- const imports = buildImports(models);
4
- const navEntries = buildNavEntries(models);
5
- const viewSwitch = buildViewSwitch(models);
8
+ const declaredViewNames = new Set(Object.keys(context.spec.views ?? {}));
9
+ const operationViews = collectOperationViews(context.spec);
10
+ const modelViews = [];
11
+ for (const m of models) {
12
+ for (const v of VIEW_TYPES) {
13
+ const viewName = `${m}${viewSuffix(v)}`;
14
+ modelViews.push({
15
+ model: m,
16
+ viewType: v,
17
+ viewName,
18
+ componentName: viewName,
19
+ declared: declaredViewNames.has(viewName)
20
+ });
21
+ }
22
+ }
23
+ const business = modelViews.filter((e) => e.declared);
24
+ const devModelViews = modelViews.filter((e) => !e.declared);
25
+ const imports = buildImports(modelViews, operationViews);
26
+ const navBusiness = buildNavGroup("Business", business.map((e) => ({
27
+ label: e.viewName,
28
+ onSelect: `setSelection({ kind: 'model', model: '${e.model}', view: '${e.viewType}' })`,
29
+ activeExpr: `selection.kind === 'model' && selection.model === '${e.model}' && selection.view === '${e.viewType}'`
30
+ })), business.length > 0 ? void 0 : "No declared views in this spec. Declare one in spec.views to curate its layout.");
31
+ const navDev = buildNavGroup("Dev", [
32
+ ...devModelViews.map((e) => ({
33
+ label: e.viewName,
34
+ onSelect: `setSelection({ kind: 'model', model: '${e.model}', view: '${e.viewType}' })`,
35
+ activeExpr: `selection.kind === 'model' && selection.model === '${e.model}' && selection.view === '${e.viewType}'`
36
+ })),
37
+ ...operationViews.map((op) => ({
38
+ label: op.viewName,
39
+ onSelect: `setSelection({ kind: 'op', viewName: '${op.viewName}' })`,
40
+ activeExpr: `selection.kind === 'op' && selection.viewName === '${op.viewName}'`
41
+ }))
42
+ ]);
43
+ const viewSwitch = buildViewSwitch(modelViews, operationViews);
44
+ const initialSelection = buildInitialSelection(business, devModelViews, operationViews);
6
45
  return `/**
7
46
  * App.tsx \u2014 generated by @specverse/realize (ReactAppStarter)
8
47
  *
9
48
  * Safe to edit. Edits are preserved across regeneration via content
10
- * hashing. This is a minimal shell: a sidebar listing every
11
- * (model, view-type) plus a main area that renders the selection.
12
- * Swap in react-router or your preferred routing library when the
13
- * app needs URL-driven navigation.
49
+ * hashing. Four-tab shell (Views / Models / Services / Events):
50
+ * the Views tab carries the user-facing app (declared + inferred
51
+ * views), the other three are a maintenance / debug GUI over the
52
+ * same backend.
14
53
  */
15
54
  import { useState } from 'react';
16
55
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
56
+ import { useResizableSidebar } from './hooks/useResizableSidebar';
57
+ import { AppEventsProvider, useRecentEvents } from './hooks/useAppEvents';
17
58
  ${imports}
18
59
 
19
60
  const queryClient = new QueryClient({
@@ -22,87 +63,278 @@ const queryClient = new QueryClient({
22
63
  },
23
64
  });
24
65
 
25
- type Selection = {
26
- model: string;
27
- view: 'list' | 'detail' | 'form' | 'dashboard';
28
- entityId?: string | number;
29
- };
66
+ type Tab = 'views' | 'models' | 'services' | 'events';
30
67
 
31
- function Inner() {
32
- const [selection, setSelection] = useState<Selection>(${models.length > 0 ? `{ model: '${models[0]}', view: 'list' }` : `{ model: '', view: 'list' }`});
68
+ type Selection =
69
+ | { kind: 'model'; model: string; view: 'list' | 'detail' | 'form' | 'dashboard'; entityId?: string | number }
70
+ | { kind: 'op'; viewName: string };
33
71
 
34
- const select = (model: string, view: Selection['view']) =>
35
- setSelection({ model, view });
72
+ function Inner() {
73
+ const [tab, setTab] = useState<Tab>('views');
74
+ const [selection, setSelection] = useState<Selection>(${initialSelection});
75
+ const [modelsTabPick, setModelsTabPick] = useState<string>(${models.length > 0 ? `'${models[0]}'` : `''`});
76
+ const { width: sidebarWidth, isResizing, startResizing } = useResizableSidebar({ defaultWidth: 256 });
36
77
 
37
78
  return (
38
- <div className="min-h-screen flex bg-gray-50 dark:bg-gray-950">
39
- <aside className="w-64 shrink-0 border-r border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
40
- <div className="p-4 border-b border-gray-200 dark:border-gray-800">
41
- <h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">App</h1>
42
- </div>
43
- <nav className="p-2 space-y-4">
44
- ${navEntries}
79
+ <div className="min-h-screen flex flex-col bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
80
+ <header className="flex items-center gap-4 px-6 py-3 border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
81
+ <h1 className="text-lg font-semibold">App</h1>
82
+ <nav className="flex gap-1">
83
+ <TabButton active={tab === 'views'} onClick={() => setTab('views')}>Views</TabButton>
84
+ <TabButton active={tab === 'models'} onClick={() => setTab('models')}>Models</TabButton>
85
+ <TabButton active={tab === 'services'} onClick={() => setTab('services')}>Services</TabButton>
86
+ <TabButton active={tab === 'events'} onClick={() => setTab('events')}>Events</TabButton>
45
87
  </nav>
46
- </aside>
88
+ </header>
47
89
 
48
- <main className="flex-1 overflow-auto">
90
+ <div className="flex-1 flex overflow-hidden">
91
+ {tab === 'views' && (
92
+ <>
93
+ <aside
94
+ style={{ width: sidebarWidth }}
95
+ className="shrink-0 overflow-auto border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
96
+ >
97
+ ${navBusiness}
98
+ ${navDev}
99
+ </aside>
100
+ <div
101
+ role="separator"
102
+ aria-orientation="vertical"
103
+ onMouseDown={startResizing}
104
+ className={\`w-1 shrink-0 cursor-col-resize hover:bg-blue-500/40 \${isResizing ? 'bg-blue-500/60' : 'bg-transparent'}\`}
105
+ />
106
+ <main className="flex-1 overflow-auto">
49
107
  ${viewSwitch}
50
- </main>
108
+ </main>
109
+ </>
110
+ )}
111
+
112
+ {tab === 'models' && (
113
+ ${buildModelsTab(models)}
114
+ )}
115
+ {tab === 'services' && (
116
+ ${buildServicesTab(operationViews)}
117
+ )}
118
+ {tab === 'events' && <EventsTab />}
119
+ </div>
51
120
  </div>
52
121
  );
53
122
  }
54
123
 
124
+ function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
125
+ return (
126
+ <button
127
+ type="button"
128
+ onClick={onClick}
129
+ className={\`px-3 py-1.5 text-sm font-medium rounded-md transition-colors \${active ? 'bg-blue-500/10 text-blue-600 ring-1 ring-blue-500/40 dark:text-blue-400' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200'}\`}
130
+ >
131
+ {children}
132
+ </button>
133
+ );
134
+ }
135
+
136
+ /**
137
+ * EventsTab \u2014 live view of the recent-events buffer populated by
138
+ * AppEventsProvider. Newest at top, clear button resets the buffer
139
+ * (doesn't affect server-side event state).
140
+ */
141
+ function EventsTab() {
142
+ const { recentEvents, clear } = useRecentEvents();
143
+ const reversed = [...recentEvents].reverse();
144
+ return (
145
+ <main className="flex-1 flex flex-col overflow-hidden">
146
+ <div className="flex items-center justify-between px-6 py-3 border-b border-gray-200 dark:border-gray-800">
147
+ <div>
148
+ <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Events</h2>
149
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Live stream of CURED events over the WebSocket. Oldest buffered first.</p>
150
+ </div>
151
+ <button
152
+ type="button"
153
+ onClick={clear}
154
+ disabled={recentEvents.length === 0}
155
+ className="rounded border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-60 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
156
+ >
157
+ Clear buffer ({recentEvents.length})
158
+ </button>
159
+ </div>
160
+ <div className="flex-1 overflow-auto p-6">
161
+ {reversed.length === 0 ? (
162
+ <p className="text-sm text-gray-400 italic">No events yet. Create/update/delete an entity in another tab and it will appear here.</p>
163
+ ) : (
164
+ <ul className="space-y-2">
165
+ {reversed.map((e, idx) => (
166
+ <li key={reversed.length - idx} className="rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
167
+ <div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-800">
168
+ <span className="font-mono text-sm text-blue-600 dark:text-blue-400">{e.event}</span>
169
+ <span className="text-xs text-gray-500 dark:text-gray-400">{new Date(e.timestamp).toLocaleTimeString()}</span>
170
+ </div>
171
+ <pre className="px-4 py-2 text-xs text-gray-700 dark:text-gray-300 overflow-x-auto">{JSON.stringify(e.payload, null, 2)}</pre>
172
+ </li>
173
+ ))}
174
+ </ul>
175
+ )}
176
+ </div>
177
+ </main>
178
+ );
179
+ }
180
+
181
+ /** Placeholder used by Models / Services tabs when the spec declares
182
+ * no models or no services. Kept even when the current spec has both
183
+ * populated \u2014 users who later remove the declarations re-trigger the
184
+ * fallback branch without the component going missing. */
185
+ function TabPlaceholder({ label, note }: { label: string; note: string }) {
186
+ return (
187
+ <div className="flex-1 flex items-center justify-center p-12">
188
+ <div className="text-center max-w-md">
189
+ <h2 className="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">{label}</h2>
190
+ <p className="text-sm text-gray-500 dark:text-gray-400">{note}</p>
191
+ </div>
192
+ </div>
193
+ );
194
+ }
195
+ void TabPlaceholder; // TS unused-warning suppression \u2014 see comment above.
196
+
55
197
  export default function App() {
56
198
  return (
57
199
  <QueryClientProvider client={queryClient}>
58
- <Inner />
200
+ <AppEventsProvider>
201
+ <Inner />
202
+ </AppEventsProvider>
59
203
  </QueryClientProvider>
60
204
  );
61
205
  }
62
206
  `;
63
207
  }
64
- function buildImports(models) {
65
- return models.flatMap((m) => [
66
- `import { ${m}ListView } from './views/${m}ListView';`,
67
- `import { ${m}DetailView } from './views/${m}DetailView';`,
68
- `import { ${m}FormView } from './views/${m}FormView';`,
69
- `import { ${m}DashboardView } from './views/${m}DashboardView';`
70
- ]).join("\n");
208
+ function buildImports(modelViews, operationViews) {
209
+ const lines = [];
210
+ for (const e of modelViews) {
211
+ lines.push(`import { ${e.componentName} } from './views/${e.viewName}';`);
212
+ }
213
+ for (const op of operationViews) {
214
+ lines.push(`import { ${op.viewName} } from './views/${op.viewName}';`);
215
+ }
216
+ return lines.join("\n");
71
217
  }
72
- function buildNavEntries(models) {
73
- if (models.length === 0) {
74
- return ' <p className="text-sm text-gray-400 px-2">No models in this spec.</p>';
218
+ function buildInitialSelection(business, devModelViews, operationViews) {
219
+ if (business.length > 0) {
220
+ return `{ kind: 'model', model: '${business[0].model}', view: '${business[0].viewType}' }`;
221
+ }
222
+ if (devModelViews.length > 0) {
223
+ return `{ kind: 'model', model: '${devModelViews[0].model}', view: '${devModelViews[0].viewType}' }`;
224
+ }
225
+ if (operationViews.length > 0) {
226
+ return `{ kind: 'op', viewName: '${operationViews[0].viewName}' }`;
75
227
  }
76
- return models.map((m) => ` <div>
77
- <div className="px-2 pb-1 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
78
- ${m}
79
- </div>
80
- <button type="button" onClick={() => select('${m}', 'list')} className="${navButtonCls("list")}">List</button>
81
- <button type="button" onClick={() => select('${m}', 'dashboard')} className="${navButtonCls("dashboard")}">Dashboard</button>
82
- <button type="button" onClick={() => select('${m}', 'form')} className="${navButtonCls("form")}">Form</button>
83
- </div>`).join("\n");
228
+ return `{ kind: 'model', model: '', view: 'list' }`;
84
229
  }
85
- function navButtonCls(_view) {
86
- return "block w-full text-left rounded px-2 py-1 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800";
230
+ function buildNavGroup(heading, entries, emptyNote) {
231
+ const headingLine = ` <div className="px-4 pb-2 pt-3 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
232
+ ${heading}
233
+ </div>`;
234
+ const baseCls = "block w-full text-left rounded-md px-3 py-1.5 text-sm transition-colors";
235
+ const activeCls = "bg-blue-500/10 text-blue-600 ring-1 ring-blue-500/40 dark:text-blue-400";
236
+ const inactiveCls = "text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200";
237
+ if (entries.length === 0) {
238
+ const note = emptyNote ? ` <p className="px-3 py-2 text-xs text-gray-400 italic">${emptyNote}</p>` : "";
239
+ return `${headingLine}
240
+ <nav className="px-2 space-y-0.5">
241
+ ${note}
242
+ </nav>`;
243
+ }
244
+ const buttons = entries.map((e) => ` <button type="button" onClick={() => ${e.onSelect}} className={\`${baseCls} \${${e.activeExpr} ? '${activeCls}' : '${inactiveCls}'}\`}>${e.label}</button>`).join("\n");
245
+ return `${headingLine}
246
+ <nav className="px-2 space-y-0.5">
247
+ ${buttons}
248
+ </nav>`;
87
249
  }
88
- function buildViewSwitch(models) {
250
+ function buildServicesTab(operationViews) {
251
+ const services = operationViews.filter((op) => op.source === "service");
252
+ if (services.length === 0) {
253
+ return ` <TabPlaceholder label="Services" note="No services declared in this spec. Add a 'services:' block to main.specly to populate this directory." />`;
254
+ }
255
+ const byService = /* @__PURE__ */ new Map();
256
+ for (const op of services) {
257
+ if (!byService.has(op.serviceName)) byService.set(op.serviceName, []);
258
+ byService.get(op.serviceName).push(op);
259
+ }
260
+ const serviceCards = [...byService.entries()].map(([svcName, ops]) => {
261
+ const opButtons = ops.map((op) => {
262
+ const paramsLabel = op.parameters ? Object.keys(op.parameters).length === 0 ? "no params" : Object.keys(op.parameters).join(", ") : "no params";
263
+ const returns = op.returns ? ` \u2192 ${escapeJsx(op.returns)}` : "";
264
+ return ` <button type="button" onClick={() => { setTab('views'); setSelection({ kind: 'op', viewName: '${op.viewName}' }); }} className="block w-full text-left rounded-md border border-gray-200 px-4 py-3 text-sm hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800 transition-colors">
265
+ <div className="font-mono text-sm text-blue-600 dark:text-blue-400">${op.operationName}</div>
266
+ <div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
267
+ ${paramsLabel}${returns}
268
+ </div>
269
+ </button>`;
270
+ }).join("\n");
271
+ return ` <div className="mb-6">
272
+ <h3 className="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">${svcName}</h3>
273
+ <div className="space-y-2">
274
+ ${opButtons}
275
+ </div>
276
+ </div>`;
277
+ }).join("\n");
278
+ return ` <main className="flex-1 overflow-auto p-6">
279
+ <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-6">Services</h2>
280
+ ${serviceCards}
281
+ </main>`;
282
+ }
283
+ function escapeJsx(s) {
284
+ return s.replace(/[<>&]/g, (ch) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[ch]);
285
+ }
286
+ function buildModelsTab(models) {
89
287
  if (models.length === 0) {
90
- return ' <p className="p-6 text-sm text-gray-400">No models \u2014 add one to your .specly file and run <code>spv realize</code>.</p>';
288
+ return ` <TabPlaceholder label="Models" note="No models in this spec \u2014 add one to start." />`;
289
+ }
290
+ const modelButtons = models.map(
291
+ (m) => ` <button type="button" onClick={() => setModelsTabPick('${m}')} className={\`block w-full text-left rounded-md px-3 py-1.5 text-sm transition-colors \${modelsTabPick === '${m}' ? 'bg-blue-500/10 text-blue-600 ring-1 ring-blue-500/40 dark:text-blue-400' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200'}\`}>${m}</button>`
292
+ ).join("\n");
293
+ const switchBranches = models.map((m) => ` {modelsTabPick === '${m}' && <${m}FormView />}`).join("\n");
294
+ return ` <>
295
+ <aside className="w-56 shrink-0 overflow-auto border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
296
+ <div className="px-4 pt-5 pb-3">
297
+ <div className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Models</div>
298
+ </div>
299
+ <nav className="px-2 space-y-0.5">
300
+ ${modelButtons}
301
+ </nav>
302
+ </aside>
303
+ <main className="flex-1 overflow-auto">
304
+ ${switchBranches}
305
+ </main>
306
+ </>`;
307
+ }
308
+ function buildViewSwitch(modelViews, operationViews) {
309
+ const models = [...new Set(modelViews.map((e) => e.model))];
310
+ if (models.length === 0 && operationViews.length === 0) {
311
+ return ' <p className="p-6 text-sm text-gray-400">No models or operations \u2014 add to your .specly file and run <code>spv realize</code>.</p>';
91
312
  }
92
- const branches = models.flatMap((m) => [
93
- ` {selection.model === '${m}' && selection.view === 'list' && (
94
- <${m}ListView onSelect={item => setSelection({ model: '${m}', view: 'detail', entityId: (item as any).id })} onCreate={() => setSelection({ model: '${m}', view: 'form' })} />
313
+ const navHandler = `(navModel: string, navId: string | number) => setSelection({ kind: 'model', model: navModel, view: 'detail', entityId: navId })`;
314
+ const branches = [];
315
+ for (const m of models) {
316
+ branches.push(
317
+ ` {selection.kind === 'model' && selection.model === '${m}' && selection.view === 'list' && (
318
+ <${m}ListView onSelect={item => setSelection({ kind: 'model', model: '${m}', view: 'detail', entityId: (item as any).id })} onCreate={() => setSelection({ kind: 'model', model: '${m}', view: 'form' })} onNavigate={${navHandler}} />
95
319
  )}`,
96
- ` {selection.model === '${m}' && selection.view === 'detail' && selection.entityId !== undefined && (
97
- <${m}DetailView entityId={selection.entityId} onEdit={item => setSelection({ model: '${m}', view: 'form', entityId: (item as any).id })} onBack={() => setSelection({ model: '${m}', view: 'list' })} onDeleted={() => setSelection({ model: '${m}', view: 'list' })} />
320
+ ` {selection.kind === 'model' && selection.model === '${m}' && selection.view === 'detail' && (
321
+ <${m}DetailView entityId={selection.entityId} onEntityChange={id => setSelection({ kind: 'model', model: '${m}', view: 'detail', entityId: id })} onNavigate={${navHandler}} />
98
322
  )}`,
99
- ` {selection.model === '${m}' && selection.view === 'form' && (
100
- <${m}FormView mode={selection.entityId ? 'update' : 'create'} entityId={selection.entityId} onSuccess={() => setSelection({ model: '${m}', view: 'list' })} onCancel={() => setSelection({ model: '${m}', view: 'list' })} />
323
+ ` {selection.kind === 'model' && selection.model === '${m}' && selection.view === 'form' && (
324
+ <${m}FormView mode={selection.entityId ? 'update' : 'create'} entityId={selection.entityId} onSuccess={() => setSelection({ kind: 'model', model: '${m}', view: 'list' })} onDeleted={() => setSelection({ kind: 'model', model: '${m}', view: 'list' })} onSelectEntity={item => setSelection({ kind: 'model', model: '${m}', view: 'form', entityId: (item as any).id })} />
101
325
  )}`,
102
- ` {selection.model === '${m}' && selection.view === 'dashboard' && (
103
- <${m}DashboardView onSelect={item => setSelection({ model: '${m}', view: 'detail', entityId: (item as any).id })} />
326
+ ` {selection.kind === 'model' && selection.model === '${m}' && selection.view === 'dashboard' && (
327
+ <${m}DashboardView onSelect={item => setSelection({ kind: 'model', model: '${m}', view: 'detail', entityId: (item as any).id })} />
104
328
  )}`
105
- ]);
329
+ );
330
+ }
331
+ for (const op of operationViews) {
332
+ branches.push(
333
+ ` {selection.kind === 'op' && selection.viewName === '${op.viewName}' && (
334
+ <${op.viewName} />
335
+ )}`
336
+ );
337
+ }
106
338
  return branches.join("\n");
107
339
  }
108
340
  export {
@@ -33,8 +33,45 @@ function buildFKMap(model) {
33
33
  }
34
34
  return map;
35
35
  }
36
+ function extractHasManyTargets(model, modelSchemas) {
37
+ const rels = model.relationships ?? {};
38
+ const out = [];
39
+ const parentLower = model.name.charAt(0).toLowerCase() + model.name.slice(1);
40
+ for (const [name, rawDef] of Object.entries(rels)) {
41
+ const parsed = parseHasMany(rawDef);
42
+ if (!parsed) continue;
43
+ let foreignKey = `${parentLower}Id`;
44
+ if (modelSchemas && modelSchemas[parsed]) {
45
+ const targetRels = modelSchemas[parsed].relationships ?? {};
46
+ for (const [targetRelName, targetRelDef] of Object.entries(targetRels)) {
47
+ const backTarget = parseBelongsTo(targetRelDef);
48
+ if (backTarget === model.name) {
49
+ foreignKey = `${targetRelName}Id`;
50
+ break;
51
+ }
52
+ }
53
+ }
54
+ out.push({ name, target: parsed, foreignKey });
55
+ }
56
+ return out;
57
+ }
58
+ function parseHasMany(def) {
59
+ if (typeof def === "string") {
60
+ const parts = def.trim().split(/\s+/);
61
+ if (parts[0] === "hasMany" && parts[1]) return parts[1];
62
+ return null;
63
+ }
64
+ if (def && typeof def === "object") {
65
+ const o = def;
66
+ if (o.type === "hasMany") {
67
+ return o.target || o.to || o.model || o.targetModel || null;
68
+ }
69
+ }
70
+ return null;
71
+ }
36
72
  export {
37
73
  buildFKMap,
38
74
  extractBelongsToTargets,
75
+ extractHasManyTargets,
39
76
  pluralize
40
77
  };
@@ -1,128 +1,13 @@
1
- import { METADATA_FIELDS } from "@specverse/runtime/views/core";
2
- import { buildFKMap } from "./belongs-to.js";
3
- const METADATA_FIELD_NAMES = new Set(METADATA_FIELDS);
1
+ import { walkPattern } from "@specverse/runtime/views/core";
2
+ import { emitJsxSource } from "./emit-jsx-source.js";
4
3
  function composeDashboardBody(context) {
5
- const modelName = context.model.name;
6
- const previewColumns = inferPreviewColumns(context.model);
7
- const enumFields = inferEnumFields(context.model);
8
- const fkMap = buildFKMap(context.model);
9
- const lines = [];
10
- lines.push('<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">');
11
- lines.push(...renderTotalCard(modelName));
12
- for (const enumField of enumFields) {
13
- lines.push(...renderEnumBreakdownCard(enumField));
14
- }
15
- if (enumFields.length === 0) {
16
- lines.push(...renderPlaceholderCard());
17
- }
18
- lines.push("</div>");
19
- lines.push("");
20
- lines.push("{/* TODO: add aggregation metrics (averages / sums / time series)");
21
- lines.push(" by adding backend endpoints and per-metric hooks. Wire them in");
22
- lines.push(" alongside the count cards above. */}");
23
- lines.push("");
24
- lines.push('<div className="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900">');
25
- lines.push(' <div className="border-b border-gray-200 dark:border-gray-700 px-6 py-3">');
26
- lines.push(' <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider">');
27
- lines.push(` Recent ${humanize(modelName)}s`);
28
- lines.push(" </h3>");
29
- lines.push(" </div>");
30
- if (previewColumns.length === 0) {
31
- lines.push(' <div className="px-6 py-4 text-sm text-gray-400">No displayable fields.</div>');
32
- } else {
33
- lines.push(' <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">');
34
- lines.push(" <thead>");
35
- lines.push(" <tr>");
36
- for (const col of previewColumns) {
37
- const fk = fkMap.get(col);
38
- const headerLabel = humanize(fk ? fk.name : col);
39
- lines.push(
40
- ` <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">` + headerLabel + `</th>`
41
- );
42
- }
43
- lines.push(" </tr>");
44
- lines.push(" </thead>");
45
- lines.push(' <tbody className="divide-y divide-gray-200 dark:divide-gray-700">');
46
- lines.push(" {preview.map((item, idx) => (");
47
- lines.push(" <tr");
48
- lines.push(" key={idx}");
49
- lines.push(" onClick={() => onSelect?.(item)}");
50
- lines.push(' className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"');
51
- lines.push(" >");
52
- for (const col of previewColumns) {
53
- const fk = fkMap.get(col);
54
- const expr = fk ? `{resolveEntityDisplayName((item as any).${col}, ${fk.name}Options)}` : `{String((item as any).${col} ?? '')}`;
55
- lines.push(
56
- ` <td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">${expr}</td>`
57
- );
58
- }
59
- lines.push(" </tr>");
60
- lines.push(" ))}");
61
- lines.push(" {preview.length === 0 && (");
62
- lines.push(` <tr><td colSpan={${previewColumns.length}} className="px-6 py-4 text-sm text-gray-400">No records yet.</td></tr>`);
63
- lines.push(" )}");
64
- lines.push(" </tbody>");
65
- lines.push(" </table>");
66
- }
67
- lines.push("</div>");
68
- return lines.join("\n");
69
- }
70
- function renderTotalCard(modelName) {
71
- const pluralLower = humanize(modelName).toLowerCase() + "s";
72
- return [
73
- ' <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">',
74
- ' <p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">',
75
- ` Total ${pluralLower}`,
76
- " </p>",
77
- ' <p className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">',
78
- " {items.length}",
79
- " </p>",
80
- " </div>"
81
- ];
82
- }
83
- function renderEnumBreakdownCard(field) {
84
- const cards = [];
85
- for (const value of field.values) {
86
- cards.push(
87
- ' <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">',
88
- ' <p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">',
89
- ` ${humanize(field.name)}: ${humanize(value)}`,
90
- " </p>",
91
- ' <p className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">',
92
- ` {items.filter((i: any) => i.${field.name} === ${JSON.stringify(value)}).length}`,
93
- " </p>",
94
- " </div>"
95
- );
96
- }
97
- return cards;
98
- }
99
- function renderPlaceholderCard() {
100
- return [
101
- ' <div className="rounded-lg border border-dashed border-gray-300 p-6 text-sm text-gray-400 dark:border-gray-600">',
102
- " Add a metric here \u2014 e.g. a sum, average, or time-windowed count.",
103
- " </div>"
104
- ];
105
- }
106
- function inferEnumFields(model) {
107
- const out = [];
108
- const attrs = model.attributes ?? {};
109
- for (const [name, rawDef] of Object.entries(attrs)) {
110
- const def = rawDef;
111
- const values = def?.values;
112
- if (Array.isArray(values) && values.length > 0 && values.length <= 6) {
113
- out.push({ name, values });
114
- }
115
- }
116
- return out.slice(0, 1);
117
- }
118
- function inferPreviewColumns(model) {
119
- const attrs = model.attributes ?? {};
120
- const attrCols = Object.keys(attrs).filter((n) => !METADATA_FIELD_NAMES.has(n));
121
- const fkCols = [...buildFKMap(model).keys()].filter((k) => !attrCols.includes(k));
122
- return [...fkCols, ...attrCols].slice(0, Math.max(5, fkCols.length + 1));
123
- }
124
- function humanize(name) {
125
- return name.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase()).trim();
4
+ const imports = /* @__PURE__ */ new Set();
5
+ const nodes = walkPattern("dashboard-view", {
6
+ model: context.model,
7
+ modelSchemas: context.modelSchemas,
8
+ view: context.view
9
+ });
10
+ return emitJsxSource(nodes, { imports, indent: 6 });
126
11
  }
127
12
  export {
128
13
  composeDashboardBody