@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
|
@@ -25,18 +25,19 @@ export function emitEntityDisplay(): string {
|
|
|
25
25
|
* @specverse/realize (ReactAppStarter); edit freely — nothing in
|
|
26
26
|
* the factory's regeneration will clobber your edits.
|
|
27
27
|
*/
|
|
28
|
-
export function getEntityDisplayName(entity:
|
|
29
|
-
if (!entity) return '';
|
|
28
|
+
export function getEntityDisplayName(entity: unknown): string {
|
|
29
|
+
if (!entity || typeof entity !== 'object') return '';
|
|
30
|
+
const record = entity as Record<string, unknown>;
|
|
30
31
|
|
|
31
32
|
const candidates = ['name', 'title', 'displayName', 'label', 'username', 'email'];
|
|
32
33
|
for (const key of candidates) {
|
|
33
|
-
const value =
|
|
34
|
+
const value = record[key];
|
|
34
35
|
if (typeof value === 'string' && value.trim().length > 0) {
|
|
35
36
|
return value;
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
const id =
|
|
40
|
+
const id = record.id;
|
|
40
41
|
if (typeof id === 'string') {
|
|
41
42
|
return id.length > 8 ? id.slice(0, 8) + '…' : id;
|
|
42
43
|
}
|
|
@@ -62,5 +63,282 @@ export function resolveEntityDisplayName(
|
|
|
62
63
|
);
|
|
63
64
|
return match ? getEntityDisplayName(match) : String(id);
|
|
64
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Format an arbitrary value for display in list cells, detail rows,
|
|
69
|
+
* and dashboard previews. Handles the cases where \`String(v ?? '')\`
|
|
70
|
+
* produces ugly output: nulls become em-dashes, booleans become
|
|
71
|
+
* "Yes"/"No", nested objects become \`{k: v, ...}\` summaries
|
|
72
|
+
* instead of "[object Object]", ISO datetimes are localized.
|
|
73
|
+
*
|
|
74
|
+
* Single source of truth for value display. Resolvers / emitters
|
|
75
|
+
* call this from expression strings.
|
|
76
|
+
*/
|
|
77
|
+
export function formatDisplayValue(value: unknown): string {
|
|
78
|
+
if (value === null || value === undefined) return '—';
|
|
79
|
+
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
|
80
|
+
if (value instanceof Date) return value.toLocaleString();
|
|
81
|
+
if (typeof value === 'string' && /^\\d{4}-\\d{2}-\\d{2}T/.test(value)) {
|
|
82
|
+
return new Date(value).toLocaleString();
|
|
83
|
+
}
|
|
84
|
+
if (Array.isArray(value)) return \`[\${value.length} items]\`;
|
|
85
|
+
if (typeof value === 'object') {
|
|
86
|
+
const keys = Object.keys(value as Record<string, unknown>);
|
|
87
|
+
if (keys.length === 0) return '{}';
|
|
88
|
+
if (keys.length <= 5) {
|
|
89
|
+
const rec = value as Record<string, unknown>;
|
|
90
|
+
return keys.map(k => \`\${k}: \${formatDisplayValue(rec[k])}\`).join(', ');
|
|
91
|
+
}
|
|
92
|
+
return \`{\${keys.length} fields}\`;
|
|
93
|
+
}
|
|
94
|
+
return String(value);
|
|
95
|
+
}
|
|
96
|
+
`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Source of `src/hooks/useAppEvents.ts`.
|
|
101
|
+
*
|
|
102
|
+
* Connects to the backend's `/ws` endpoint, subscribes to every
|
|
103
|
+
* CURED event, and invalidates the matching React Query cache on
|
|
104
|
+
* Created / Updated / Deleted / Evolved events. Drives live-refresh
|
|
105
|
+
* everywhere in the app.
|
|
106
|
+
*
|
|
107
|
+
* Message shapes the backend sends:
|
|
108
|
+
* - `{ type: 'connected', timestamp }` — open handshake.
|
|
109
|
+
* - `{ type: 'subscribed-all', events }` — ack of subscribe-all.
|
|
110
|
+
* - `{ type: 'event', event: '{Model}{Action}', payload, timestamp }`.
|
|
111
|
+
*
|
|
112
|
+
* Message the client sends:
|
|
113
|
+
* - `{ type: 'subscribe-all' }` once the socket opens.
|
|
114
|
+
*/
|
|
115
|
+
export function emitUseAppEvents(): string {
|
|
116
|
+
return `/**
|
|
117
|
+
* AppEventsProvider + useAppEvents + useRecentEvents
|
|
118
|
+
*
|
|
119
|
+
* One WebSocket connection to the backend's /ws, shared across the
|
|
120
|
+
* app. Two consumers:
|
|
121
|
+
* - useAppEvents(): React Query invalidation on mutations (no-op
|
|
122
|
+
* call — side effect is via the provider).
|
|
123
|
+
* - useRecentEvents(): the Events tab's scrolling stream display.
|
|
124
|
+
*
|
|
125
|
+
* Inlined into this project by @specverse/realize (ReactAppStarter).
|
|
126
|
+
* Edit freely.
|
|
127
|
+
*/
|
|
128
|
+
import { useEffect, useState, useContext, createContext, type ReactNode } from 'react';
|
|
129
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
130
|
+
|
|
131
|
+
export interface AppEvent {
|
|
132
|
+
event: string;
|
|
133
|
+
payload: any;
|
|
134
|
+
timestamp: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface AppEventsCtx {
|
|
138
|
+
recentEvents: AppEvent[];
|
|
139
|
+
clear: () => void;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const EventsContext = createContext<AppEventsCtx | null>(null);
|
|
143
|
+
|
|
144
|
+
function pluralize(s: string): string {
|
|
145
|
+
if (/[^aeiou]y$/i.test(s)) return s.slice(0, -1) + 'ies';
|
|
146
|
+
if (/(s|x|z|ch|sh)$/i.test(s)) return s + 'es';
|
|
147
|
+
return s + 's';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function deriveWsUrl(): string {
|
|
151
|
+
const apiBase = import.meta.env.VITE_API_BASE_URL || '';
|
|
152
|
+
if (apiBase) return apiBase.replace(/^http/, 'ws') + '/ws';
|
|
153
|
+
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
154
|
+
return \`\${proto}//\${window.location.host}/ws\`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Parse "CategoryCreated" → "Category". Returns null for event names
|
|
158
|
+
* that don't end in one of the 4 CURED verbs. */
|
|
159
|
+
function modelFromEvent(eventName: string): string | null {
|
|
160
|
+
const m = eventName.match(/^(.+)(Created|Updated|Deleted|Evolved)$/);
|
|
161
|
+
return m ? m[1] : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Provider — wrap the app once. Maintains a single WS connection,
|
|
166
|
+
* invalidates React Query caches on mutation events, and keeps a
|
|
167
|
+
* buffer of recent events (default 100) for the Events tab.
|
|
168
|
+
*/
|
|
169
|
+
export function AppEventsProvider({ children, bufferSize = 100 }: { children: ReactNode; bufferSize?: number }) {
|
|
170
|
+
const qc = useQueryClient();
|
|
171
|
+
const [recentEvents, setRecentEvents] = useState<AppEvent[]>([]);
|
|
172
|
+
|
|
173
|
+
const clear = () => setRecentEvents([]);
|
|
174
|
+
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
let cancelled = false;
|
|
177
|
+
let ws: WebSocket | null = null;
|
|
178
|
+
let reconnectDelay = 500;
|
|
179
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
180
|
+
|
|
181
|
+
const connect = () => {
|
|
182
|
+
if (cancelled) return;
|
|
183
|
+
try {
|
|
184
|
+
ws = new WebSocket(deriveWsUrl());
|
|
185
|
+
} catch {
|
|
186
|
+
scheduleReconnect();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
ws.addEventListener('open', () => {
|
|
191
|
+
if (cancelled) { ws?.close(); return; }
|
|
192
|
+
reconnectDelay = 500;
|
|
193
|
+
ws?.send(JSON.stringify({ type: 'subscribe-all' }));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
ws.addEventListener('message', (evt) => {
|
|
197
|
+
if (cancelled) return;
|
|
198
|
+
let data: any;
|
|
199
|
+
try { data = JSON.parse(evt.data as string); } catch { return; }
|
|
200
|
+
if (data?.type !== 'event') return;
|
|
201
|
+
const appEvent: AppEvent = {
|
|
202
|
+
event: data.event,
|
|
203
|
+
payload: data.payload,
|
|
204
|
+
timestamp: data.timestamp ?? new Date().toISOString(),
|
|
205
|
+
};
|
|
206
|
+
// Append + cap at bufferSize.
|
|
207
|
+
setRecentEvents(prev => {
|
|
208
|
+
const next = [...prev, appEvent];
|
|
209
|
+
return next.length > bufferSize ? next.slice(-bufferSize) : next;
|
|
210
|
+
});
|
|
211
|
+
// Invalidate React Query cache for affected model.
|
|
212
|
+
const model = modelFromEvent(data.event);
|
|
213
|
+
if (model) {
|
|
214
|
+
const resource = pluralize(model).toLowerCase();
|
|
215
|
+
qc.invalidateQueries({ queryKey: [resource] });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
ws.addEventListener('close', () => {
|
|
220
|
+
if (!cancelled) scheduleReconnect();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
ws.addEventListener('error', () => {
|
|
224
|
+
try { ws?.close(); } catch { /* ignore */ }
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const scheduleReconnect = () => {
|
|
229
|
+
if (cancelled) return;
|
|
230
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
231
|
+
reconnectTimer = setTimeout(() => {
|
|
232
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 10_000);
|
|
233
|
+
connect();
|
|
234
|
+
}, reconnectDelay);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// Microtask-deferred connect — StrictMode-safe (synchronous
|
|
238
|
+
// mount/unmount/remount won't leave a half-open socket).
|
|
239
|
+
Promise.resolve().then(() => { if (!cancelled) connect(); });
|
|
240
|
+
|
|
241
|
+
return () => {
|
|
242
|
+
cancelled = true;
|
|
243
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
244
|
+
if (ws && ws.readyState === WebSocket.CONNECTING) {
|
|
245
|
+
ws.addEventListener('open', () => ws?.close(), { once: true });
|
|
246
|
+
} else if (ws) {
|
|
247
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}, [qc, bufferSize]);
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<EventsContext.Provider value={{ recentEvents, clear }}>
|
|
254
|
+
{children}
|
|
255
|
+
</EventsContext.Provider>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Shorthand for components that don't care about the buffer — they
|
|
261
|
+
* get live-refresh behavior "for free" because the provider runs
|
|
262
|
+
* query invalidation. Kept as a no-op hook so sites that called it
|
|
263
|
+
* under the old API still type-check and still document intent.
|
|
264
|
+
*/
|
|
265
|
+
export function useAppEvents() {
|
|
266
|
+
// No-op — the Provider does the work. Retained to preserve intent
|
|
267
|
+
// at call sites like "the app needs live refresh".
|
|
268
|
+
useContext(EventsContext);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Read the recent-events buffer — used by the Events tab to render
|
|
273
|
+
* a scrolling stream. Returns [] if no AppEventsProvider is mounted.
|
|
274
|
+
*/
|
|
275
|
+
export function useRecentEvents(): AppEventsCtx {
|
|
276
|
+
return useContext(EventsContext) ?? { recentEvents: [], clear: () => {} };
|
|
277
|
+
}
|
|
278
|
+
`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Source of `src/hooks/useResizableSidebar.ts`.
|
|
283
|
+
*
|
|
284
|
+
* Ported verbatim from `@specverse/runtime/views/react/hooks/
|
|
285
|
+
* useResizableSidebar` (app-demo re-exports the same hook). Drag-to-
|
|
286
|
+
* resize sidebar width with localStorage persistence.
|
|
287
|
+
*/
|
|
288
|
+
export function emitUseResizableSidebar(): string {
|
|
289
|
+
return `/**
|
|
290
|
+
* useResizableSidebar — drag-to-resize sidebar width.
|
|
291
|
+
*
|
|
292
|
+
* Inlined into this project by @specverse/realize (ReactAppStarter);
|
|
293
|
+
* edit freely — nothing in the factory's regeneration will clobber
|
|
294
|
+
* your edits.
|
|
295
|
+
*
|
|
296
|
+
* Returns { width, isResizing, startResizing } — width is the current
|
|
297
|
+
* pixel value, isResizing is true during an active drag, startResizing
|
|
298
|
+
* is an onMouseDown handler to attach to the drag handle element.
|
|
299
|
+
*/
|
|
300
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
301
|
+
|
|
302
|
+
export interface ResizableSidebarOptions {
|
|
303
|
+
defaultWidth?: number;
|
|
304
|
+
minWidth?: number;
|
|
305
|
+
maxWidth?: number;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function useResizableSidebar(options: ResizableSidebarOptions = {}) {
|
|
309
|
+
const { defaultWidth = 256, minWidth = 200, maxWidth = 500 } = options;
|
|
310
|
+
const [width, setWidth] = useState(defaultWidth);
|
|
311
|
+
const [isResizing, setIsResizing] = useState(false);
|
|
312
|
+
|
|
313
|
+
const startResizing = useCallback(() => {
|
|
314
|
+
setIsResizing(true);
|
|
315
|
+
}, []);
|
|
316
|
+
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
if (!isResizing) return;
|
|
319
|
+
|
|
320
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
321
|
+
const newWidth = Math.min(maxWidth, Math.max(minWidth, e.clientX));
|
|
322
|
+
setWidth(newWidth);
|
|
323
|
+
};
|
|
324
|
+
const handleMouseUp = () => {
|
|
325
|
+
setIsResizing(false);
|
|
326
|
+
document.body.style.cursor = '';
|
|
327
|
+
document.body.style.userSelect = '';
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
document.body.style.cursor = 'col-resize';
|
|
331
|
+
document.body.style.userSelect = 'none';
|
|
332
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
333
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
334
|
+
|
|
335
|
+
return () => {
|
|
336
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
337
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
338
|
+
};
|
|
339
|
+
}, [isResizing, minWidth, maxWidth]);
|
|
340
|
+
|
|
341
|
+
return { width, isResizing, startResizing };
|
|
342
|
+
}
|
|
65
343
|
`;
|
|
66
344
|
}
|
|
@@ -1,146 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* List-view body composer for ReactAppStarter
|
|
2
|
+
* List-view body composer for ReactAppStarter (Phase 3)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Now a thin wrapper around the pattern walker. The 'what a list
|
|
5
|
+
* view looks like' knowledge lives in the pattern registry (section
|
|
6
|
+
* content declarations) + section resolvers in
|
|
7
|
+
* `@specverse/runtime/views/core/section-resolvers/`. This composer
|
|
8
|
+
* just calls `walkPattern('list-view', context)` and emits the
|
|
9
|
+
* resulting RenderNode tree as JSX source.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* inside the tbody with a `{filtered.map(...)}` expression. The shell
|
|
15
|
-
* stays canonical; only the row-iteration JSX is synthesized here.
|
|
11
|
+
* Notice the shape: no column inference, no row-iteration JSX, no
|
|
12
|
+
* header rendering in this file. That logic now lives in the walker
|
|
13
|
+
* where app-demo and ReactAppRuntime can use it too.
|
|
16
14
|
*
|
|
17
|
-
*
|
|
15
|
+
* This is the first of four composers migrated to the walker path.
|
|
16
|
+
* detail, form, dashboard follow the same shape.
|
|
18
17
|
*/
|
|
19
18
|
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import type { EmitContext, ModelSpec } from './view-emitter.js';
|
|
24
|
-
import { buildFKMap } from './belongs-to.js';
|
|
19
|
+
import { walkPattern } from '@specverse/runtime/views/core';
|
|
20
|
+
import type { EmitContext } from './view-emitter.js';
|
|
21
|
+
import { emitJsxSource } from './emit-jsx-source.js';
|
|
25
22
|
|
|
26
|
-
/**
|
|
27
|
-
* Sentinel token. Must be ASCII-safe (no curly braces) so it survives
|
|
28
|
-
* the html-to-jsx transform unchanged, and must be unique enough not
|
|
29
|
-
* to appear in real HTML output.
|
|
30
|
-
*/
|
|
31
|
-
const TBODY_SENTINEL = '__SPECVERSE_TBODY_ROWS__';
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Compose the list view's body as JSX-safe source. Returned string is
|
|
35
|
-
* meant to be dropped at `{{BODY}}` inside `skeletons/list.tsx.template`.
|
|
36
|
-
*/
|
|
37
23
|
export function composeListBody(context: EmitContext): string {
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// Header label for an FK column should use the relationship name
|
|
50
|
-
// ("Owner") not the raw column name ("Owner Id"). The cell rendering
|
|
51
|
-
// in buildRowMap applies the matching FK→name resolution.
|
|
52
|
-
const headers = columns.map(col => {
|
|
53
|
-
const fk = fkMap.get(col);
|
|
54
|
-
return humanize(fk ? fk.name : col);
|
|
24
|
+
// The walker returns a rendering-agnostic RenderNode tree. The
|
|
25
|
+
// imports set collects any capitalised composite components the
|
|
26
|
+
// tree references — view-emitter injects them at the top of the
|
|
27
|
+
// generated .tsx file. For list views today the tree is all
|
|
28
|
+
// lowercase HTML tags, so imports is typically empty.
|
|
29
|
+
const imports = new Set<string>();
|
|
30
|
+
const nodes = walkPattern('list-view', {
|
|
31
|
+
model: context.model,
|
|
32
|
+
modelSchemas: context.modelSchemas as any,
|
|
33
|
+
view: context.view,
|
|
55
34
|
});
|
|
56
35
|
|
|
57
|
-
|
|
58
|
-
const shellHtml = adapter.components.table.render({
|
|
59
|
-
properties: { columns: headers },
|
|
60
|
-
children: TBODY_SENTINEL,
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
// Transform the static HTML shell to JSX-safe source. The sentinel
|
|
64
|
-
// lands in a text position inside the <tbody> and survives the
|
|
65
|
-
// transform untouched.
|
|
66
|
-
const shellJsx = htmlToJsx(shellHtml);
|
|
67
|
-
|
|
68
|
-
// Build the row-iteration JSX that replaces the sentinel. The
|
|
69
|
-
// skeleton's useMemo produces `filtered: Model[]`, so the map
|
|
70
|
-
// expression binds over that. onSelect is a prop the skeleton
|
|
71
|
-
// declares. Delete action wires to the deleteItem mutation.
|
|
72
|
-
const rowMap = buildRowMap(columns, fkMap);
|
|
73
|
-
|
|
74
|
-
if (!shellJsx.includes(TBODY_SENTINEL)) {
|
|
75
|
-
// Defensive: catches adapter output changes that no longer flow
|
|
76
|
-
// `children` into the tbody. Caught at composer time, not runtime.
|
|
77
|
-
throw new Error(
|
|
78
|
-
'composeListBody: tbody sentinel not present in adapter output. ' +
|
|
79
|
-
'The canonical Tailwind adapter may have changed its table rendering.'
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return shellJsx.replace(TBODY_SENTINEL, rowMap);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Select the non-metadata attributes of a model. Delegates to the
|
|
88
|
-
* canonical pattern library so the column set is identical to what
|
|
89
|
-
* the runtime React adapter picks for the same model — parity test P3
|
|
90
|
-
* relies on this being one-and-the-same function.
|
|
91
|
-
*/
|
|
92
|
-
function inferColumns(context: EmitContext): string[] {
|
|
93
|
-
return inferFieldsFromSchema(context.modelSchemas, context.model.name);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Convert a camelCase attribute name to Title-Case words for display
|
|
98
|
-
* as a column header. Matches the convention used by runtime's
|
|
99
|
-
* pattern adapter (see `humanize` in runtime react-pattern-adapter).
|
|
100
|
-
*/
|
|
101
|
-
function humanize(name: string): string {
|
|
102
|
-
return name
|
|
103
|
-
.replace(/([A-Z])/g, ' $1')
|
|
104
|
-
.replace(/^./, c => c.toUpperCase())
|
|
105
|
-
.trim();
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Emit the `{filtered.map(...)}` JSX expression that renders each
|
|
110
|
-
* row. Keep the output readable so users inspecting the generated
|
|
111
|
-
* file can follow what's happening.
|
|
112
|
-
*/
|
|
113
|
-
function buildRowMap(columns: string[], fkMap: Map<string, { name: string; target: string }>): string {
|
|
114
|
-
// Type-cast to `any` to avoid TS generic syntax (`<string, unknown>`)
|
|
115
|
-
// inside JSX expressions, which the TSX parser can't always
|
|
116
|
-
// disambiguate from a JSX opening tag. `any` is the right choice for
|
|
117
|
-
// starter-kit output anyway — the user will often reshape the row
|
|
118
|
-
// type after editing.
|
|
119
|
-
//
|
|
120
|
-
// FK columns render through `resolveEntityDisplayName(id, options)`
|
|
121
|
-
// so the user sees a name instead of a UUID. The options array
|
|
122
|
-
// (`${relName}Options`) is populated by the hook call wired into
|
|
123
|
-
// the list skeleton via view-emitter's RELATED_HOOKS substitution.
|
|
124
|
-
const cells = columns
|
|
125
|
-
.map(col => {
|
|
126
|
-
const fk = fkMap.get(col);
|
|
127
|
-
const expr = fk
|
|
128
|
-
? `{resolveEntityDisplayName((item as any).${col}, ${fk.name}Options)}`
|
|
129
|
-
: `{String((item as any).${col} ?? '')}`;
|
|
130
|
-
return ` <td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">` +
|
|
131
|
-
`${expr}</td>`;
|
|
132
|
-
})
|
|
133
|
-
.join('\n');
|
|
134
|
-
|
|
135
|
-
return [
|
|
136
|
-
'{filtered.map((item, idx) => (',
|
|
137
|
-
' <tr',
|
|
138
|
-
' key={idx}',
|
|
139
|
-
' onClick={() => onSelect?.(item)}',
|
|
140
|
-
' className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"',
|
|
141
|
-
' >',
|
|
142
|
-
cells,
|
|
143
|
-
' </tr>',
|
|
144
|
-
'))}',
|
|
145
|
-
].join('\n');
|
|
36
|
+
return emitJsxSource(nodes, { imports, indent: 6 });
|
|
146
37
|
}
|