@specverse/engines 4.2.2 → 4.3.1
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/inference/ui-contracts/rules/action-buttons-present.d.ts +8 -5
- package/dist/inference/ui-contracts/rules/action-buttons-present.d.ts.map +1 -1
- package/dist/inference/ui-contracts/rules/action-buttons-present.js +85 -19
- package/dist/inference/ui-contracts/rules/action-buttons-present.js.map +1 -1
- package/dist/inference/ui-contracts/test-case-types.d.ts +3 -0
- package/dist/inference/ui-contracts/test-case-types.d.ts.map +1 -1
- package/dist/inference/ui-contracts/translator.d.ts.map +1 -1
- package/dist/inference/ui-contracts/translator.js +4 -0
- package/dist/inference/ui-contracts/translator.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 -2
|
@@ -1,41 +1,105 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* App.tsx generator for ReactAppStarter
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* URL-level view selection; for now, internal state drives it.
|
|
4
|
+
* Two-surface shell:
|
|
5
|
+
* 1. Business views declared in the .specly spec (user's app).
|
|
6
|
+
* 2. Dev GUI — 4-tab maintenance surface over the same backend.
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* The 4 tabs:
|
|
9
|
+
* - Views: declared views + auto-inferred CRUD + operation views.
|
|
10
|
+
* BUSINESS group = views with an entry in spec.views.
|
|
11
|
+
* DEV group = inferred CRUD + operation views.
|
|
12
|
+
* Declaring a view in the spec graduates it from Dev → Business.
|
|
13
|
+
* - Models: raw CRUD for any model (R2 — placeholder for now).
|
|
14
|
+
* - Services: directory of services + their operations (R3).
|
|
15
|
+
* - Events: WebSocket event stream (R4).
|
|
16
|
+
*
|
|
17
|
+
* Not using react-router — the starter output is deliberately low-
|
|
18
|
+
* magic. Users can add URL-level routing when they need it.
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import type { ExpandedSpec } from './views-generator.js';
|
|
22
|
+
import { collectOperationViews, type OperationViewDescriptor } from './operation-view-generator.js';
|
|
16
23
|
|
|
17
24
|
export interface AppGeneratorContext {
|
|
18
25
|
spec: ExpandedSpec;
|
|
19
26
|
manifest?: unknown;
|
|
20
27
|
}
|
|
21
28
|
|
|
29
|
+
type ViewType = 'list' | 'detail' | 'form' | 'dashboard';
|
|
30
|
+
const VIEW_TYPES: ViewType[] = ['list', 'detail', 'form', 'dashboard'];
|
|
31
|
+
|
|
32
|
+
function viewSuffix(v: ViewType): string {
|
|
33
|
+
return v.charAt(0).toUpperCase() + v.slice(1) + 'View';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Per-view metadata the sidebar + routing need. */
|
|
37
|
+
interface ModelViewEntry {
|
|
38
|
+
model: string;
|
|
39
|
+
viewType: ViewType;
|
|
40
|
+
viewName: string; // "CategoryListView"
|
|
41
|
+
componentName: string; // same as viewName for our codegen
|
|
42
|
+
declared: boolean; // true iff spec.views[viewName] exists
|
|
43
|
+
}
|
|
44
|
+
|
|
22
45
|
export async function generate(context: AppGeneratorContext): Promise<string> {
|
|
23
46
|
const models = Object.keys(context.spec.models ?? {});
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
47
|
+
const declaredViewNames = new Set(Object.keys(context.spec.views ?? {}));
|
|
48
|
+
const operationViews = collectOperationViews(context.spec);
|
|
49
|
+
|
|
50
|
+
const modelViews: ModelViewEntry[] = [];
|
|
51
|
+
for (const m of models) {
|
|
52
|
+
for (const v of VIEW_TYPES) {
|
|
53
|
+
const viewName = `${m}${viewSuffix(v)}`;
|
|
54
|
+
modelViews.push({
|
|
55
|
+
model: m,
|
|
56
|
+
viewType: v,
|
|
57
|
+
viewName,
|
|
58
|
+
componentName: viewName,
|
|
59
|
+
declared: declaredViewNames.has(viewName),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const business = modelViews.filter(e => e.declared);
|
|
65
|
+
// Dev = inferred CRUD + operation views
|
|
66
|
+
const devModelViews = modelViews.filter(e => !e.declared);
|
|
67
|
+
|
|
68
|
+
const imports = buildImports(modelViews, operationViews);
|
|
69
|
+
const navBusiness = buildNavGroup('Business', business.map(e => ({
|
|
70
|
+
label: e.viewName,
|
|
71
|
+
onSelect: `setSelection({ kind: 'model', model: '${e.model}', view: '${e.viewType}' })`,
|
|
72
|
+
activeExpr: `selection.kind === 'model' && selection.model === '${e.model}' && selection.view === '${e.viewType}'`,
|
|
73
|
+
})), business.length > 0 ? undefined : 'No declared views in this spec. Declare one in spec.views to curate its layout.');
|
|
74
|
+
const navDev = buildNavGroup('Dev', [
|
|
75
|
+
...devModelViews.map(e => ({
|
|
76
|
+
label: e.viewName,
|
|
77
|
+
onSelect: `setSelection({ kind: 'model', model: '${e.model}', view: '${e.viewType}' })`,
|
|
78
|
+
activeExpr: `selection.kind === 'model' && selection.model === '${e.model}' && selection.view === '${e.viewType}'`,
|
|
79
|
+
})),
|
|
80
|
+
...operationViews.map(op => ({
|
|
81
|
+
label: op.viewName,
|
|
82
|
+
onSelect: `setSelection({ kind: 'op', viewName: '${op.viewName}' })`,
|
|
83
|
+
activeExpr: `selection.kind === 'op' && selection.viewName === '${op.viewName}'`,
|
|
84
|
+
})),
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
const viewSwitch = buildViewSwitch(modelViews, operationViews);
|
|
88
|
+
const initialSelection = buildInitialSelection(business, devModelViews, operationViews);
|
|
27
89
|
|
|
28
90
|
return `/**
|
|
29
91
|
* App.tsx — generated by @specverse/realize (ReactAppStarter)
|
|
30
92
|
*
|
|
31
93
|
* Safe to edit. Edits are preserved across regeneration via content
|
|
32
|
-
* hashing.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
94
|
+
* hashing. Four-tab shell (Views / Models / Services / Events):
|
|
95
|
+
* the Views tab carries the user-facing app (declared + inferred
|
|
96
|
+
* views), the other three are a maintenance / debug GUI over the
|
|
97
|
+
* same backend.
|
|
36
98
|
*/
|
|
37
99
|
import { useState } from 'react';
|
|
38
100
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
101
|
+
import { useResizableSidebar } from './hooks/useResizableSidebar';
|
|
102
|
+
import { AppEventsProvider, useRecentEvents } from './hooks/useAppEvents';
|
|
39
103
|
${imports}
|
|
40
104
|
|
|
41
105
|
const queryClient = new QueryClient({
|
|
@@ -44,40 +108,143 @@ const queryClient = new QueryClient({
|
|
|
44
108
|
},
|
|
45
109
|
});
|
|
46
110
|
|
|
47
|
-
type
|
|
48
|
-
model: string;
|
|
49
|
-
view: 'list' | 'detail' | 'form' | 'dashboard';
|
|
50
|
-
entityId?: string | number;
|
|
51
|
-
};
|
|
111
|
+
type Tab = 'views' | 'models' | 'services' | 'events';
|
|
52
112
|
|
|
53
|
-
|
|
54
|
-
|
|
113
|
+
type Selection =
|
|
114
|
+
| { kind: 'model'; model: string; view: 'list' | 'detail' | 'form' | 'dashboard'; entityId?: string | number }
|
|
115
|
+
| { kind: 'op'; viewName: string };
|
|
55
116
|
|
|
56
|
-
|
|
57
|
-
|
|
117
|
+
function Inner() {
|
|
118
|
+
const [tab, setTab] = useState<Tab>('views');
|
|
119
|
+
const [selection, setSelection] = useState<Selection>(${initialSelection});
|
|
120
|
+
const [modelsTabPick, setModelsTabPick] = useState<string>(${models.length > 0 ? `'${models[0]}'` : `''`});
|
|
121
|
+
const { width: sidebarWidth, isResizing, startResizing } = useResizableSidebar({ defaultWidth: 256 });
|
|
58
122
|
|
|
59
123
|
return (
|
|
60
|
-
<div className="min-h-screen flex bg-gray-50 dark:bg-gray-950">
|
|
61
|
-
<
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
124
|
+
<div className="min-h-screen flex flex-col bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
|
|
125
|
+
<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">
|
|
126
|
+
<h1 className="text-lg font-semibold">App</h1>
|
|
127
|
+
<nav className="flex gap-1">
|
|
128
|
+
<TabButton active={tab === 'views'} onClick={() => setTab('views')}>Views</TabButton>
|
|
129
|
+
<TabButton active={tab === 'models'} onClick={() => setTab('models')}>Models</TabButton>
|
|
130
|
+
<TabButton active={tab === 'services'} onClick={() => setTab('services')}>Services</TabButton>
|
|
131
|
+
<TabButton active={tab === 'events'} onClick={() => setTab('events')}>Events</TabButton>
|
|
67
132
|
</nav>
|
|
68
|
-
</
|
|
133
|
+
</header>
|
|
69
134
|
|
|
70
|
-
<
|
|
135
|
+
<div className="flex-1 flex overflow-hidden">
|
|
136
|
+
{tab === 'views' && (
|
|
137
|
+
<>
|
|
138
|
+
<aside
|
|
139
|
+
style={{ width: sidebarWidth }}
|
|
140
|
+
className="shrink-0 overflow-auto border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
|
|
141
|
+
>
|
|
142
|
+
${navBusiness}
|
|
143
|
+
${navDev}
|
|
144
|
+
</aside>
|
|
145
|
+
<div
|
|
146
|
+
role="separator"
|
|
147
|
+
aria-orientation="vertical"
|
|
148
|
+
onMouseDown={startResizing}
|
|
149
|
+
className={\`w-1 shrink-0 cursor-col-resize hover:bg-blue-500/40 \${isResizing ? 'bg-blue-500/60' : 'bg-transparent'}\`}
|
|
150
|
+
/>
|
|
151
|
+
<main className="flex-1 overflow-auto">
|
|
71
152
|
${viewSwitch}
|
|
72
|
-
|
|
153
|
+
</main>
|
|
154
|
+
</>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{tab === 'models' && (
|
|
158
|
+
${buildModelsTab(models)}
|
|
159
|
+
)}
|
|
160
|
+
{tab === 'services' && (
|
|
161
|
+
${buildServicesTab(operationViews)}
|
|
162
|
+
)}
|
|
163
|
+
{tab === 'events' && <EventsTab />}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
|
170
|
+
return (
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
onClick={onClick}
|
|
174
|
+
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'}\`}
|
|
175
|
+
>
|
|
176
|
+
{children}
|
|
177
|
+
</button>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* EventsTab — live view of the recent-events buffer populated by
|
|
183
|
+
* AppEventsProvider. Newest at top, clear button resets the buffer
|
|
184
|
+
* (doesn't affect server-side event state).
|
|
185
|
+
*/
|
|
186
|
+
function EventsTab() {
|
|
187
|
+
const { recentEvents, clear } = useRecentEvents();
|
|
188
|
+
const reversed = [...recentEvents].reverse();
|
|
189
|
+
return (
|
|
190
|
+
<main className="flex-1 flex flex-col overflow-hidden">
|
|
191
|
+
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-200 dark:border-gray-800">
|
|
192
|
+
<div>
|
|
193
|
+
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Events</h2>
|
|
194
|
+
<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>
|
|
195
|
+
</div>
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
onClick={clear}
|
|
199
|
+
disabled={recentEvents.length === 0}
|
|
200
|
+
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"
|
|
201
|
+
>
|
|
202
|
+
Clear buffer ({recentEvents.length})
|
|
203
|
+
</button>
|
|
204
|
+
</div>
|
|
205
|
+
<div className="flex-1 overflow-auto p-6">
|
|
206
|
+
{reversed.length === 0 ? (
|
|
207
|
+
<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>
|
|
208
|
+
) : (
|
|
209
|
+
<ul className="space-y-2">
|
|
210
|
+
{reversed.map((e, idx) => (
|
|
211
|
+
<li key={reversed.length - idx} className="rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
|
212
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-800">
|
|
213
|
+
<span className="font-mono text-sm text-blue-600 dark:text-blue-400">{e.event}</span>
|
|
214
|
+
<span className="text-xs text-gray-500 dark:text-gray-400">{new Date(e.timestamp).toLocaleTimeString()}</span>
|
|
215
|
+
</div>
|
|
216
|
+
<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>
|
|
217
|
+
</li>
|
|
218
|
+
))}
|
|
219
|
+
</ul>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
</main>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Placeholder used by Models / Services tabs when the spec declares
|
|
227
|
+
* no models or no services. Kept even when the current spec has both
|
|
228
|
+
* populated — users who later remove the declarations re-trigger the
|
|
229
|
+
* fallback branch without the component going missing. */
|
|
230
|
+
function TabPlaceholder({ label, note }: { label: string; note: string }) {
|
|
231
|
+
return (
|
|
232
|
+
<div className="flex-1 flex items-center justify-center p-12">
|
|
233
|
+
<div className="text-center max-w-md">
|
|
234
|
+
<h2 className="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">{label}</h2>
|
|
235
|
+
<p className="text-sm text-gray-500 dark:text-gray-400">{note}</p>
|
|
236
|
+
</div>
|
|
73
237
|
</div>
|
|
74
238
|
);
|
|
75
239
|
}
|
|
240
|
+
void TabPlaceholder; // TS unused-warning suppression — see comment above.
|
|
76
241
|
|
|
77
242
|
export default function App() {
|
|
78
243
|
return (
|
|
79
244
|
<QueryClientProvider client={queryClient}>
|
|
80
|
-
<
|
|
245
|
+
<AppEventsProvider>
|
|
246
|
+
<Inner />
|
|
247
|
+
</AppEventsProvider>
|
|
81
248
|
</QueryClientProvider>
|
|
82
249
|
);
|
|
83
250
|
}
|
|
@@ -88,54 +255,193 @@ export default function App() {
|
|
|
88
255
|
// Helpers
|
|
89
256
|
// ──────────────────────────────────────────────────────────────────────
|
|
90
257
|
|
|
91
|
-
function buildImports(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
`import { ${
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
258
|
+
function buildImports(modelViews: ModelViewEntry[], operationViews: OperationViewDescriptor[]): string {
|
|
259
|
+
const lines: string[] = [];
|
|
260
|
+
for (const e of modelViews) {
|
|
261
|
+
lines.push(`import { ${e.componentName} } from './views/${e.viewName}';`);
|
|
262
|
+
}
|
|
263
|
+
for (const op of operationViews) {
|
|
264
|
+
lines.push(`import { ${op.viewName} } from './views/${op.viewName}';`);
|
|
265
|
+
}
|
|
266
|
+
return lines.join('\n');
|
|
98
267
|
}
|
|
99
268
|
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
269
|
+
function buildInitialSelection(
|
|
270
|
+
business: ModelViewEntry[],
|
|
271
|
+
devModelViews: ModelViewEntry[],
|
|
272
|
+
operationViews: OperationViewDescriptor[],
|
|
273
|
+
): string {
|
|
274
|
+
// Prefer the first business view; fall back to the first dev model
|
|
275
|
+
// view; then operation view; else empty.
|
|
276
|
+
if (business.length > 0) {
|
|
277
|
+
return `{ kind: 'model', model: '${business[0].model}', view: '${business[0].viewType}' }`;
|
|
278
|
+
}
|
|
279
|
+
if (devModelViews.length > 0) {
|
|
280
|
+
return `{ kind: 'model', model: '${devModelViews[0].model}', view: '${devModelViews[0].viewType}' }`;
|
|
281
|
+
}
|
|
282
|
+
if (operationViews.length > 0) {
|
|
283
|
+
return `{ kind: 'op', viewName: '${operationViews[0].viewName}' }`;
|
|
103
284
|
}
|
|
104
|
-
return
|
|
105
|
-
<div className="px-2 pb-1 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
106
|
-
${m}
|
|
107
|
-
</div>
|
|
108
|
-
<button type="button" onClick={() => select('${m}', 'list')} className="${navButtonCls('list')}">List</button>
|
|
109
|
-
<button type="button" onClick={() => select('${m}', 'dashboard')} className="${navButtonCls('dashboard')}">Dashboard</button>
|
|
110
|
-
<button type="button" onClick={() => select('${m}', 'form')} className="${navButtonCls('form')}">Form</button>
|
|
111
|
-
</div>`).join('\n');
|
|
285
|
+
return `{ kind: 'model', model: '', view: 'list' }`;
|
|
112
286
|
}
|
|
113
287
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
288
|
+
interface NavEntry {
|
|
289
|
+
label: string;
|
|
290
|
+
/** Full expression string: `setSelection({...})`. */
|
|
291
|
+
onSelect: string;
|
|
292
|
+
/** JS boolean expression that is true when this entry is active. */
|
|
293
|
+
activeExpr: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function buildNavGroup(heading: string, entries: NavEntry[], emptyNote?: string): string {
|
|
297
|
+
const headingLine = ` <div className="px-4 pb-2 pt-3 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
298
|
+
${heading}
|
|
299
|
+
</div>`;
|
|
300
|
+
const baseCls = 'block w-full text-left rounded-md px-3 py-1.5 text-sm transition-colors';
|
|
301
|
+
const activeCls = 'bg-blue-500/10 text-blue-600 ring-1 ring-blue-500/40 dark:text-blue-400';
|
|
302
|
+
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';
|
|
303
|
+
|
|
304
|
+
if (entries.length === 0) {
|
|
305
|
+
const note = emptyNote
|
|
306
|
+
? ` <p className="px-3 py-2 text-xs text-gray-400 italic">${emptyNote}</p>`
|
|
307
|
+
: '';
|
|
308
|
+
return `${headingLine}
|
|
309
|
+
<nav className="px-2 space-y-0.5">
|
|
310
|
+
${note}
|
|
311
|
+
</nav>`;
|
|
312
|
+
}
|
|
313
|
+
const buttons = entries
|
|
314
|
+
.map(e => ` <button type="button" onClick={() => ${e.onSelect}} className={\`${baseCls} \${${e.activeExpr} ? '${activeCls}' : '${inactiveCls}'}\`}>${e.label}</button>`)
|
|
315
|
+
.join('\n');
|
|
316
|
+
return `${headingLine}
|
|
317
|
+
<nav className="px-2 space-y-0.5">
|
|
318
|
+
${buttons}
|
|
319
|
+
</nav>`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Services tab — directory of services + their operations.
|
|
324
|
+
* Each operation is a button that jumps to the corresponding
|
|
325
|
+
* operation view in the Views tab (no executor duplication — the
|
|
326
|
+
* actual parameter form lives with the view).
|
|
327
|
+
*/
|
|
328
|
+
function buildServicesTab(operationViews: OperationViewDescriptor[]): string {
|
|
329
|
+
const services = operationViews.filter(op => op.source === 'service');
|
|
330
|
+
if (services.length === 0) {
|
|
331
|
+
return ` <TabPlaceholder label="Services" note="No services declared in this spec. Add a 'services:' block to main.specly to populate this directory." />`;
|
|
332
|
+
}
|
|
333
|
+
// Group operations by serviceName.
|
|
334
|
+
const byService = new Map<string, OperationViewDescriptor[]>();
|
|
335
|
+
for (const op of services) {
|
|
336
|
+
if (!byService.has(op.serviceName)) byService.set(op.serviceName, []);
|
|
337
|
+
byService.get(op.serviceName)!.push(op);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const serviceCards = [...byService.entries()]
|
|
341
|
+
.map(([svcName, ops]) => {
|
|
342
|
+
const opButtons = ops
|
|
343
|
+
.map(op => {
|
|
344
|
+
const paramsLabel = op.parameters
|
|
345
|
+
? Object.keys(op.parameters as any).length === 0
|
|
346
|
+
? 'no params'
|
|
347
|
+
: Object.keys(op.parameters as any).join(', ')
|
|
348
|
+
: 'no params';
|
|
349
|
+
const returns = op.returns ? ` → ${escapeJsx(op.returns)}` : '';
|
|
350
|
+
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">
|
|
351
|
+
<div className="font-mono text-sm text-blue-600 dark:text-blue-400">${op.operationName}</div>
|
|
352
|
+
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
353
|
+
${paramsLabel}${returns}
|
|
354
|
+
</div>
|
|
355
|
+
</button>`;
|
|
356
|
+
})
|
|
357
|
+
.join('\n');
|
|
358
|
+
return ` <div className="mb-6">
|
|
359
|
+
<h3 className="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">${svcName}</h3>
|
|
360
|
+
<div className="space-y-2">
|
|
361
|
+
${opButtons}
|
|
362
|
+
</div>
|
|
363
|
+
</div>`;
|
|
364
|
+
})
|
|
365
|
+
.join('\n');
|
|
366
|
+
|
|
367
|
+
return ` <main className="flex-1 overflow-auto p-6">
|
|
368
|
+
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-6">Services</h2>
|
|
369
|
+
${serviceCards}
|
|
370
|
+
</main>`;
|
|
120
371
|
}
|
|
121
372
|
|
|
122
|
-
function
|
|
373
|
+
function escapeJsx(s: string): string {
|
|
374
|
+
return s.replace(/[<>&]/g, ch => ({ '<': '<', '>': '>', '&': '&' }[ch]!));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Models tab — an admin-style CRUD surface over every model in the spec.
|
|
379
|
+
* Sub-sidebar listing each model; main area renders that model's
|
|
380
|
+
* {Model}FormView, which already contains the create/update form plus
|
|
381
|
+
* the embedded existing-entities list. Reuses existing components; no
|
|
382
|
+
* new rendering path required.
|
|
383
|
+
*/
|
|
384
|
+
function buildModelsTab(models: string[]): string {
|
|
123
385
|
if (models.length === 0) {
|
|
124
|
-
return
|
|
386
|
+
return ` <TabPlaceholder label="Models" note="No models in this spec — add one to start." />`;
|
|
125
387
|
}
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
388
|
+
const modelButtons = models
|
|
389
|
+
.map(m =>
|
|
390
|
+
` <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>`
|
|
391
|
+
)
|
|
392
|
+
.join('\n');
|
|
393
|
+
const switchBranches = models
|
|
394
|
+
.map(m => ` {modelsTabPick === '${m}' && <${m}FormView />}`)
|
|
395
|
+
.join('\n');
|
|
396
|
+
|
|
397
|
+
return ` <>
|
|
398
|
+
<aside className="w-56 shrink-0 overflow-auto border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
|
399
|
+
<div className="px-4 pt-5 pb-3">
|
|
400
|
+
<div className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Models</div>
|
|
401
|
+
</div>
|
|
402
|
+
<nav className="px-2 space-y-0.5">
|
|
403
|
+
${modelButtons}
|
|
404
|
+
</nav>
|
|
405
|
+
</aside>
|
|
406
|
+
<main className="flex-1 overflow-auto">
|
|
407
|
+
${switchBranches}
|
|
408
|
+
</main>
|
|
409
|
+
</>`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function buildViewSwitch(modelViews: ModelViewEntry[], operationViews: OperationViewDescriptor[]): string {
|
|
413
|
+
const models = [...new Set(modelViews.map(e => e.model))];
|
|
414
|
+
if (models.length === 0 && operationViews.length === 0) {
|
|
415
|
+
return ' <p className="p-6 text-sm text-gray-400">No models or operations — add to your .specly file and run <code>spv realize</code>.</p>';
|
|
416
|
+
}
|
|
417
|
+
const navHandler =
|
|
418
|
+
`(navModel: string, navId: string | number) => setSelection({ kind: 'model', model: navModel, view: 'detail', entityId: navId })`;
|
|
419
|
+
const branches: string[] = [];
|
|
420
|
+
|
|
421
|
+
for (const m of models) {
|
|
422
|
+
branches.push(
|
|
423
|
+
` {selection.kind === 'model' && selection.model === '${m}' && selection.view === 'list' && (
|
|
424
|
+
<${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}} />
|
|
129
425
|
)}`,
|
|
130
|
-
|
|
131
|
-
<${m}DetailView entityId={selection.entityId}
|
|
426
|
+
` {selection.kind === 'model' && selection.model === '${m}' && selection.view === 'detail' && (
|
|
427
|
+
<${m}DetailView entityId={selection.entityId} onEntityChange={id => setSelection({ kind: 'model', model: '${m}', view: 'detail', entityId: id })} onNavigate={${navHandler}} />
|
|
132
428
|
)}`,
|
|
133
|
-
|
|
134
|
-
<${m}FormView mode={selection.entityId ? 'update' : 'create'} entityId={selection.entityId} onSuccess={() => setSelection({ model: '${m}', view: 'list' })}
|
|
429
|
+
` {selection.kind === 'model' && selection.model === '${m}' && selection.view === 'form' && (
|
|
430
|
+
<${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 })} />
|
|
135
431
|
)}`,
|
|
136
|
-
|
|
137
|
-
<${m}DashboardView onSelect={item => setSelection({ model: '${m}', view: 'detail', entityId: (item as any).id })} />
|
|
432
|
+
` {selection.kind === 'model' && selection.model === '${m}' && selection.view === 'dashboard' && (
|
|
433
|
+
<${m}DashboardView onSelect={item => setSelection({ kind: 'model', model: '${m}', view: 'detail', entityId: (item as any).id })} />
|
|
138
434
|
)}`,
|
|
139
|
-
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
for (const op of operationViews) {
|
|
439
|
+
branches.push(
|
|
440
|
+
` {selection.kind === 'op' && selection.viewName === '${op.viewName}' && (
|
|
441
|
+
<${op.viewName} />
|
|
442
|
+
)}`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
140
446
|
return branches.join('\n');
|
|
141
447
|
}
|
|
@@ -80,3 +80,69 @@ export function buildFKMap(model: ModelSpec): Map<string, BelongsToRel> {
|
|
|
80
80
|
}
|
|
81
81
|
return map;
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A hasMany relationship — the "other side" of a belongsTo. E.g.
|
|
86
|
+
* Category has `items: hasMany Item`, which means we can filter
|
|
87
|
+
* Items by `item.categoryId === category.id` to get a Category's
|
|
88
|
+
* items. Used by the detail composer to render tabbed related lists.
|
|
89
|
+
*/
|
|
90
|
+
export interface HasManyRel {
|
|
91
|
+
/** Relationship name on the parent — e.g. "items", "assignedTasks". */
|
|
92
|
+
name: string;
|
|
93
|
+
/** Target (child) model name — e.g. "Item", "Task". */
|
|
94
|
+
target: string;
|
|
95
|
+
/**
|
|
96
|
+
* FK column on the child that points back to this parent. Typically
|
|
97
|
+
* \`${parentName.toLowerCase()}Id\` — e.g. for \`Item.belongsTo Category\`,
|
|
98
|
+
* Item's FK is \`categoryId\`. Factory B filters by this at render time.
|
|
99
|
+
*/
|
|
100
|
+
foreignKey: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function extractHasManyTargets(
|
|
104
|
+
model: ModelSpec,
|
|
105
|
+
modelSchemas?: Record<string, ModelSpec>,
|
|
106
|
+
): HasManyRel[] {
|
|
107
|
+
const rels = (model.relationships ?? {}) as Record<string, unknown>;
|
|
108
|
+
const out: HasManyRel[] = [];
|
|
109
|
+
const parentLower = model.name.charAt(0).toLowerCase() + model.name.slice(1);
|
|
110
|
+
|
|
111
|
+
for (const [name, rawDef] of Object.entries(rels)) {
|
|
112
|
+
const parsed = parseHasMany(rawDef);
|
|
113
|
+
if (!parsed) continue;
|
|
114
|
+
|
|
115
|
+
// Determine the FK on the target that points back to this model.
|
|
116
|
+
// Prefer an explicit belongsTo on the target; fall back to the
|
|
117
|
+
// conventional \`${parentLower}Id\`.
|
|
118
|
+
let foreignKey = `${parentLower}Id`;
|
|
119
|
+
if (modelSchemas && modelSchemas[parsed]) {
|
|
120
|
+
const targetRels = (modelSchemas[parsed].relationships ?? {}) as Record<string, unknown>;
|
|
121
|
+
for (const [targetRelName, targetRelDef] of Object.entries(targetRels)) {
|
|
122
|
+
const backTarget = parseBelongsTo(targetRelDef);
|
|
123
|
+
if (backTarget === model.name) {
|
|
124
|
+
foreignKey = `${targetRelName}Id`;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
out.push({ name, target: parsed, foreignKey });
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseHasMany(def: unknown): string | null {
|
|
136
|
+
if (typeof def === 'string') {
|
|
137
|
+
const parts = def.trim().split(/\s+/);
|
|
138
|
+
if (parts[0] === 'hasMany' && parts[1]) return parts[1];
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
if (def && typeof def === 'object') {
|
|
142
|
+
const o = def as { type?: string; target?: string; to?: string; model?: string; targetModel?: string };
|
|
143
|
+
if (o.type === 'hasMany') {
|
|
144
|
+
return o.target || o.to || o.model || o.targetModel || null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|