@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.
- package/dist/inference/core/specly-converter.d.ts.map +1 -1
- package/dist/inference/core/specly-converter.js +43 -22
- package/dist/inference/core/specly-converter.js.map +1 -1
- package/dist/inference/index.d.ts.map +1 -1
- package/dist/inference/index.js +29 -0
- package/dist/inference/index.js.map +1 -1
- package/dist/libs/instance-factories/applications/templates/react/index-html-generator.js +2 -2
- package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +291 -59
- package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +37 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +9 -124
- package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +9 -75
- package/dist/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.js +129 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +9 -212
- package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +260 -5
- package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +8 -50
- package/dist/libs/instance-factories/applications/templates/react-starter/operation-emitters.js +470 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/operation-view-generator.js +136 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/package-json-generator.js +2 -1
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
- package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +49 -10
- package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +223 -10
- package/dist/libs/instance-factories/applications/templates/react-starter/views-generator.js +14 -1
- package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +13 -1
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +18 -0
- package/libs/instance-factories/applications/templates/react/index-html-generator.ts +2 -2
- package/libs/instance-factories/applications/templates/react-starter/__tests__/dashboard-body-composer.test.ts +3 -1
- package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +24 -25
- package/libs/instance-factories/applications/templates/react-starter/__tests__/list-body-composer.test.ts +3 -3
- package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +2 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/parity-p3-rendered-output.test.ts +1 -1
- package/libs/instance-factories/applications/templates/react-starter/__tests__/starter-generators.test.ts +5 -4
- package/libs/instance-factories/applications/templates/react-starter/__tests__/views-generator.test.ts +11 -4
- package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +377 -71
- package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +66 -0
- package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +15 -182
- package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +16 -128
- package/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.ts +233 -0
- package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +16 -376
- package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +282 -4
- package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +26 -135
- package/libs/instance-factories/applications/templates/react-starter/operation-emitters.ts +497 -0
- package/libs/instance-factories/applications/templates/react-starter/operation-view-generator.ts +209 -0
- package/libs/instance-factories/applications/templates/react-starter/package-json-generator.ts +1 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
- package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
- package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
- package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +71 -10
- package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +359 -18
- package/libs/instance-factories/applications/templates/react-starter/views-generator.ts +28 -1
- package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +13 -1
- package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +18 -0
- package/package.json +2 -1
package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js
CHANGED
|
@@ -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
|
|
4
|
-
const
|
|
5
|
-
const
|
|
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.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
26
|
-
model: string;
|
|
27
|
-
view: 'list' | 'detail' | 'form' | 'dashboard';
|
|
28
|
-
entityId?: string | number;
|
|
29
|
-
};
|
|
66
|
+
type Tab = 'views' | 'models' | 'services' | 'events';
|
|
30
67
|
|
|
31
|
-
|
|
32
|
-
|
|
68
|
+
type Selection =
|
|
69
|
+
| { kind: 'model'; model: string; view: 'list' | 'detail' | 'form' | 'dashboard'; entityId?: string | number }
|
|
70
|
+
| { kind: 'op'; viewName: string };
|
|
33
71
|
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
<
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
</
|
|
88
|
+
</header>
|
|
47
89
|
|
|
48
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
200
|
+
<AppEventsProvider>
|
|
201
|
+
<Inner />
|
|
202
|
+
</AppEventsProvider>
|
|
59
203
|
</QueryClientProvider>
|
|
60
204
|
);
|
|
61
205
|
}
|
|
62
206
|
`;
|
|
63
207
|
}
|
|
64
|
-
function buildImports(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
`import { ${
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
73
|
-
if (
|
|
74
|
-
return
|
|
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
|
|
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
|
|
86
|
-
|
|
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
|
|
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) => ({ "<": "<", ">": ">", "&": "&" })[ch]);
|
|
285
|
+
}
|
|
286
|
+
function buildModelsTab(models) {
|
|
89
287
|
if (models.length === 0) {
|
|
90
|
-
return
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
<${m}DetailView entityId={selection.entityId}
|
|
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
|
-
|
|
100
|
-
<${m}FormView mode={selection.entityId ? 'update' : 'create'} entityId={selection.entityId} onSuccess={() => 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
|
-
|
|
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
|
};
|
package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js
CHANGED
|
@@ -1,128 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|