@specverse/engines 4.1.21 → 4.1.23
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/ui-contracts/index.d.ts +38 -0
- package/dist/inference/ui-contracts/index.d.ts.map +1 -0
- package/dist/inference/ui-contracts/index.js +212 -0
- package/dist/inference/ui-contracts/index.js.map +1 -0
- package/dist/inference/ui-contracts/rules/_shared.d.ts +32 -0
- package/dist/inference/ui-contracts/rules/_shared.d.ts.map +1 -0
- package/dist/inference/ui-contracts/rules/_shared.js +103 -0
- package/dist/inference/ui-contracts/rules/_shared.js.map +1 -0
- package/dist/inference/ui-contracts/rules/action-buttons-present.d.ts +21 -0
- package/dist/inference/ui-contracts/rules/action-buttons-present.d.ts.map +1 -0
- package/dist/inference/ui-contracts/rules/action-buttons-present.js +62 -0
- package/dist/inference/ui-contracts/rules/action-buttons-present.js.map +1 -0
- package/dist/inference/ui-contracts/rules/create-reflects-in-list.d.ts +22 -0
- package/dist/inference/ui-contracts/rules/create-reflects-in-list.d.ts.map +1 -0
- package/dist/inference/ui-contracts/rules/create-reflects-in-list.js +48 -0
- package/dist/inference/ui-contracts/rules/create-reflects-in-list.js.map +1 -0
- package/dist/inference/ui-contracts/rules/delete-reflects-in-list.d.ts +22 -0
- package/dist/inference/ui-contracts/rules/delete-reflects-in-list.d.ts.map +1 -0
- package/dist/inference/ui-contracts/rules/delete-reflects-in-list.js +50 -0
- package/dist/inference/ui-contracts/rules/delete-reflects-in-list.js.map +1 -0
- package/dist/inference/ui-contracts/rules/detail-view-renders.d.ts +24 -0
- package/dist/inference/ui-contracts/rules/detail-view-renders.d.ts.map +1 -0
- package/dist/inference/ui-contracts/rules/detail-view-renders.js +34 -0
- package/dist/inference/ui-contracts/rules/detail-view-renders.js.map +1 -0
- package/dist/inference/ui-contracts/rules/evolve-reflects-in-list.d.ts +21 -0
- package/dist/inference/ui-contracts/rules/evolve-reflects-in-list.d.ts.map +1 -0
- package/dist/inference/ui-contracts/rules/evolve-reflects-in-list.js +53 -0
- package/dist/inference/ui-contracts/rules/evolve-reflects-in-list.js.map +1 -0
- package/dist/inference/ui-contracts/rules/form-shows-required-indicators.d.ts +15 -0
- package/dist/inference/ui-contracts/rules/form-shows-required-indicators.d.ts.map +1 -0
- package/dist/inference/ui-contracts/rules/form-shows-required-indicators.js +38 -0
- package/dist/inference/ui-contracts/rules/form-shows-required-indicators.js.map +1 -0
- package/dist/inference/ui-contracts/rules/hasmany-shows-children-in-detail.d.ts +17 -0
- package/dist/inference/ui-contracts/rules/hasmany-shows-children-in-detail.d.ts.map +1 -0
- package/dist/inference/ui-contracts/rules/hasmany-shows-children-in-detail.js +39 -0
- package/dist/inference/ui-contracts/rules/hasmany-shows-children-in-detail.js.map +1 -0
- package/dist/inference/ui-contracts/rules/lifecycle-state-visible-in-detail.d.ts +25 -0
- package/dist/inference/ui-contracts/rules/lifecycle-state-visible-in-detail.d.ts.map +1 -0
- package/dist/inference/ui-contracts/rules/lifecycle-state-visible-in-detail.js +66 -0
- package/dist/inference/ui-contracts/rules/lifecycle-state-visible-in-detail.js.map +1 -0
- package/dist/inference/ui-contracts/rules/list-shows-business-columns.d.ts +17 -0
- package/dist/inference/ui-contracts/rules/list-shows-business-columns.d.ts.map +1 -0
- package/dist/inference/ui-contracts/rules/list-shows-business-columns.js +39 -0
- package/dist/inference/ui-contracts/rules/list-shows-business-columns.js.map +1 -0
- package/dist/inference/ui-contracts/rules/list-view-renders.d.ts +19 -0
- package/dist/inference/ui-contracts/rules/list-view-renders.d.ts.map +1 -0
- package/dist/inference/ui-contracts/rules/list-view-renders.js +29 -0
- package/dist/inference/ui-contracts/rules/list-view-renders.js.map +1 -0
- package/dist/inference/ui-contracts/rules/nav-has-model-entries.d.ts +20 -0
- package/dist/inference/ui-contracts/rules/nav-has-model-entries.d.ts.map +1 -0
- package/dist/inference/ui-contracts/rules/nav-has-model-entries.js +29 -0
- package/dist/inference/ui-contracts/rules/nav-has-model-entries.js.map +1 -0
- package/dist/inference/ui-contracts/test-case-types.d.ts +126 -0
- package/dist/inference/ui-contracts/test-case-types.d.ts.map +1 -0
- package/dist/inference/ui-contracts/test-case-types.js +14 -0
- package/dist/inference/ui-contracts/test-case-types.js.map +1 -0
- package/dist/inference/ui-contracts/translator.d.ts +17 -0
- package/dist/inference/ui-contracts/translator.d.ts.map +1 -0
- package/dist/inference/ui-contracts/translator.js +127 -0
- package/dist/inference/ui-contracts/translator.js.map +1 -0
- package/dist/libs/instance-factories/applications/templates/react/api-client-generator.js +37 -2
- package/dist/libs/instance-factories/applications/templates/react/runtime-app-tsx-generator.js +41 -4
- package/dist/libs/instance-factories/applications/templates/react/use-api-hooks-generator.js +27 -7
- package/dist/libs/instance-factories/scaffolding/templates/generic/package-json-generator.js +10 -0
- package/dist/libs/instance-factories/views/templates/react/components-generator.js +34 -23
- package/dist/libs/instance-factories/views/templates/react/hooks-generator.js +51 -81
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +204 -0
- package/dist/realize/index.js.map +1 -1
- package/libs/instance-factories/applications/templates/react/api-client-generator.ts +37 -2
- package/libs/instance-factories/applications/templates/react/runtime-app-tsx-generator.ts +41 -4
- package/libs/instance-factories/applications/templates/react/use-api-hooks-generator.ts +27 -7
- package/libs/instance-factories/scaffolding/templates/generic/package-json-generator.ts +13 -0
- package/libs/instance-factories/views/templates/react/components-generator.ts +34 -23
- package/libs/instance-factories/views/templates/react/hooks-generator.ts +72 -88
- package/package.json +1 -1
|
@@ -135,13 +135,14 @@ function pluralize(s) {
|
|
|
135
135
|
function generateListView(name, model, lower, plural, api, classified, belongsTo, lifecycle, view) {
|
|
136
136
|
const displayCols = classified.business.slice(0, 5);
|
|
137
137
|
const statusField = lifecycle?.statusField || classified.lifecycle[0]?.name;
|
|
138
|
+
const apiPath = api.replace(/^\/api/, "");
|
|
138
139
|
const relFetches = belongsTo.map((r) => {
|
|
139
140
|
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
140
141
|
return ` const [${tLower}Map, set${r.target}Map] = useState<Record<string, any>>({});`;
|
|
141
142
|
}).join("\n");
|
|
142
143
|
const relEffects = belongsTo.map((r) => {
|
|
143
144
|
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
144
|
-
return `
|
|
145
|
+
return ` apiRequest('GET', '/${tLower}s').then(data => {
|
|
145
146
|
if (Array.isArray(data)) {
|
|
146
147
|
const m: Record<string, any> = {};
|
|
147
148
|
data.forEach(e => { m[e.id] = e; });
|
|
@@ -156,6 +157,7 @@ function generateListView(name, model, lower, plural, api, classified, belongsTo
|
|
|
156
157
|
});
|
|
157
158
|
return `import { useState, useEffect } from 'react';
|
|
158
159
|
import { Link } from 'react-router-dom';
|
|
160
|
+
import { apiRequest } from '../lib/apiClient';
|
|
159
161
|
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
160
162
|
import { formatValue, formatDate, StatusBadge } from '../lib/view-helpers';
|
|
161
163
|
|
|
@@ -165,7 +167,7 @@ function ${name}() {
|
|
|
165
167
|
${relFetches}
|
|
166
168
|
|
|
167
169
|
useEffect(() => {
|
|
168
|
-
|
|
170
|
+
apiRequest('GET', '${apiPath}').then(data => {
|
|
169
171
|
setItems(Array.isArray(data) ? data : []);
|
|
170
172
|
setLoading(false);
|
|
171
173
|
}).catch(() => setLoading(false));
|
|
@@ -228,6 +230,7 @@ export default ${name};
|
|
|
228
230
|
}
|
|
229
231
|
function generateDetailView(name, model, lower, plural, api, classified, belongsTo, hasMany, lifecycle, view) {
|
|
230
232
|
const statusField = lifecycle?.statusField || classified.lifecycle[0]?.name;
|
|
233
|
+
const apiPath = api.replace(/^\/api/, "");
|
|
231
234
|
const relFetches = belongsTo.map((r) => {
|
|
232
235
|
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
233
236
|
return ` const [${tLower}Ref, set${r.target}Ref] = useState<any>(null);`;
|
|
@@ -235,7 +238,7 @@ function generateDetailView(name, model, lower, plural, api, classified, belongs
|
|
|
235
238
|
const relEffects = belongsTo.map((r) => {
|
|
236
239
|
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
237
240
|
const fk = `${r.name}_id`;
|
|
238
|
-
return ` if (data.${fk})
|
|
241
|
+
return ` if (data.${fk}) apiRequest('GET', \`/${tLower}s/\${data.${fk}}\`).then(d => set${r.target}Ref(d)).catch(() => {});`;
|
|
239
242
|
}).join("\n");
|
|
240
243
|
const hasManyFetches = hasMany.map((r) => {
|
|
241
244
|
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
@@ -243,10 +246,11 @@ function generateDetailView(name, model, lower, plural, api, classified, belongs
|
|
|
243
246
|
}).join("\n");
|
|
244
247
|
const hasManyEffects = hasMany.map((r) => {
|
|
245
248
|
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
246
|
-
return `
|
|
249
|
+
return ` apiRequest('GET', \`/${tLower}s?${lower}Id=\${data.id}\`).then(d => set${capitalize(r.name)}Items(Array.isArray(d) ? d : [])).catch(() => {});`;
|
|
247
250
|
}).join("\n");
|
|
248
251
|
return `import { useState, useEffect } from 'react';
|
|
249
252
|
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
|
253
|
+
import { apiRequest } from '../lib/apiClient';
|
|
250
254
|
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
251
255
|
import { formatValue, formatDate, StatusBadge } from '../lib/view-helpers';
|
|
252
256
|
|
|
@@ -261,7 +265,7 @@ ${hasManyFetches}
|
|
|
261
265
|
|
|
262
266
|
useEffect(() => {
|
|
263
267
|
if (!id) { setLoading(false); return; }
|
|
264
|
-
|
|
268
|
+
apiRequest('GET', \`${apiPath}/\${id}\`).then(data => {
|
|
265
269
|
setItem(data);
|
|
266
270
|
setLoading(false);
|
|
267
271
|
if (data) {
|
|
@@ -276,7 +280,7 @@ ${hasManyEffects}
|
|
|
276
280
|
|
|
277
281
|
const handleDelete = async () => {
|
|
278
282
|
if (!confirm('Delete this ${lower}?')) return;
|
|
279
|
-
await
|
|
283
|
+
await apiRequest('DELETE', \`${apiPath}/\${id}\`);
|
|
280
284
|
navigate('/${lower}list');
|
|
281
285
|
};
|
|
282
286
|
|
|
@@ -358,12 +362,13 @@ export default ${name};
|
|
|
358
362
|
function generateFormView(name, model, lower, plural, api, classified, belongsTo, lifecycle, view) {
|
|
359
363
|
const editableFields = [...classified.business, ...classified.lifecycle];
|
|
360
364
|
const statusField = lifecycle?.statusField;
|
|
365
|
+
const apiPath = api.replace(/^\/api/, "");
|
|
361
366
|
const relStates = belongsTo.map((r) => {
|
|
362
367
|
return ` const [${r.target.charAt(0).toLowerCase() + r.target.slice(1)}Options, set${r.target}Options] = useState<any[]>([]);`;
|
|
363
368
|
}).join("\n");
|
|
364
369
|
const relEffects = belongsTo.map((r) => {
|
|
365
370
|
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
366
|
-
return `
|
|
371
|
+
return ` apiRequest('GET', '/${tLower}s').then(d => set${r.target}Options(Array.isArray(d) ? d : [])).catch(() => {});`;
|
|
367
372
|
}).join("\n");
|
|
368
373
|
function inputForField(attr) {
|
|
369
374
|
const { name: n, type, required, values } = attr;
|
|
@@ -390,6 +395,7 @@ ${lifecycle.states.map((s) => ` <option value="${s}">${s.replace(/[_-]/
|
|
|
390
395
|
}
|
|
391
396
|
return `import { useState, useEffect } from 'react';
|
|
392
397
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
|
398
|
+
import { apiRequest } from '../lib/apiClient';
|
|
393
399
|
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
394
400
|
|
|
395
401
|
function ${name}() {
|
|
@@ -403,7 +409,7 @@ ${relStates}
|
|
|
403
409
|
|
|
404
410
|
useEffect(() => {
|
|
405
411
|
if (editId) {
|
|
406
|
-
|
|
412
|
+
apiRequest('GET', \`${apiPath}/\${editId}\`).then(data => setForm(data || {})).catch(() => {});
|
|
407
413
|
}
|
|
408
414
|
${relEffects}
|
|
409
415
|
}, [editId]);
|
|
@@ -418,17 +424,12 @@ ${relEffects}
|
|
|
418
424
|
setSaving(true);
|
|
419
425
|
setError('');
|
|
420
426
|
try {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
headers: { 'Content-Type': 'application/json' },
|
|
426
|
-
body: JSON.stringify(form),
|
|
427
|
-
});
|
|
428
|
-
if (!res.ok) {
|
|
429
|
-
const err = await res.json().catch(() => ({}));
|
|
430
|
-
throw new Error(err.message || 'Save failed');
|
|
427
|
+
if (editId) {
|
|
428
|
+
await apiRequest('PUT', \`${apiPath}/\${editId}\`, form);
|
|
429
|
+
} else {
|
|
430
|
+
await apiRequest('POST', '${apiPath}', form);
|
|
431
431
|
}
|
|
432
|
+
// apiRequest throws on non-2xx so reaching here means success.
|
|
432
433
|
navigate('/${lower}list');
|
|
433
434
|
} catch (err: any) {
|
|
434
435
|
setError(err.message);
|
|
@@ -490,7 +491,9 @@ export default ${name};
|
|
|
490
491
|
function generateDashboardView(name, model, lower, plural, api, classified, view, modelDef) {
|
|
491
492
|
const numericAttrs = classified.business.filter((a) => ["integer", "number", "money", "decimal", "float"].includes(a.type.toLowerCase()));
|
|
492
493
|
const metricNames = numericAttrs.map((a) => a.name).slice(0, 4);
|
|
494
|
+
const apiPath = api.replace(/^\/api/, "");
|
|
493
495
|
return `import { useState, useEffect } from 'react';
|
|
496
|
+
import { apiRequest } from '../lib/apiClient';
|
|
494
497
|
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
495
498
|
import { formatDate, StatusBadge } from '../lib/view-helpers';
|
|
496
499
|
|
|
@@ -499,7 +502,7 @@ function ${name}() {
|
|
|
499
502
|
const [loading, setLoading] = useState(true);
|
|
500
503
|
|
|
501
504
|
useEffect(() => {
|
|
502
|
-
|
|
505
|
+
apiRequest('GET', '${apiPath}').then(data => {
|
|
503
506
|
setItems(Array.isArray(data) ? data : []);
|
|
504
507
|
setLoading(false);
|
|
505
508
|
}).catch(() => setLoading(false));
|
|
@@ -552,8 +555,10 @@ export default ${name};
|
|
|
552
555
|
function generateBoardView(name, model, lower, plural, api, lifecycle, view) {
|
|
553
556
|
const states = lifecycle?.states || ["todo", "in_progress", "done"];
|
|
554
557
|
const statusField = lifecycle?.statusField || "status";
|
|
558
|
+
const apiPath = api.replace(/^\/api/, "");
|
|
555
559
|
return `import { useState, useEffect } from 'react';
|
|
556
560
|
import { Link } from 'react-router-dom';
|
|
561
|
+
import { apiRequest } from '../lib/apiClient';
|
|
557
562
|
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
558
563
|
import { statusColor } from '../lib/view-helpers';
|
|
559
564
|
|
|
@@ -562,7 +567,7 @@ function ${name}() {
|
|
|
562
567
|
const [loading, setLoading] = useState(true);
|
|
563
568
|
|
|
564
569
|
useEffect(() => {
|
|
565
|
-
|
|
570
|
+
apiRequest('GET', '${apiPath}').then(data => {
|
|
566
571
|
setItems(Array.isArray(data) ? data : []);
|
|
567
572
|
setLoading(false);
|
|
568
573
|
}).catch(() => setLoading(false));
|
|
@@ -614,8 +619,10 @@ export default ${name};
|
|
|
614
619
|
`;
|
|
615
620
|
}
|
|
616
621
|
function generateTimelineView(name, model, lower, plural, api, view) {
|
|
622
|
+
const apiPath = api.replace(/^\/api/, "");
|
|
617
623
|
return `import { useState, useEffect } from 'react';
|
|
618
624
|
import { Link } from 'react-router-dom';
|
|
625
|
+
import { apiRequest } from '../lib/apiClient';
|
|
619
626
|
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
620
627
|
import { formatDate, StatusBadge } from '../lib/view-helpers';
|
|
621
628
|
|
|
@@ -624,7 +631,7 @@ function ${name}() {
|
|
|
624
631
|
const [loading, setLoading] = useState(true);
|
|
625
632
|
|
|
626
633
|
useEffect(() => {
|
|
627
|
-
|
|
634
|
+
apiRequest('GET', '${apiPath}').then(data => {
|
|
628
635
|
const sorted = (Array.isArray(data) ? data : []).sort((a, b) =>
|
|
629
636
|
new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime()
|
|
630
637
|
);
|
|
@@ -669,7 +676,9 @@ function generateCalendarView(name, model, lower, plural, api, view, modelDef) {
|
|
|
669
676
|
const dateField = attrs.find(
|
|
670
677
|
(a) => ["startdate", "duedate", "scheduledat", "eventdate", "date"].includes(a.name.toLowerCase())
|
|
671
678
|
)?.name || "createdAt";
|
|
679
|
+
const apiPath = api.replace(/^\/api/, "");
|
|
672
680
|
return `import { useState, useEffect } from 'react';
|
|
681
|
+
import { apiRequest } from '../lib/apiClient';
|
|
673
682
|
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
674
683
|
|
|
675
684
|
function ${name}() {
|
|
@@ -678,7 +687,7 @@ function ${name}() {
|
|
|
678
687
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
|
679
688
|
|
|
680
689
|
useEffect(() => {
|
|
681
|
-
|
|
690
|
+
apiRequest('GET', '${apiPath}').then(data => {
|
|
682
691
|
setItems(Array.isArray(data) ? data : []);
|
|
683
692
|
setLoading(false);
|
|
684
693
|
}).catch(() => setLoading(false));
|
|
@@ -737,14 +746,16 @@ export default ${name};
|
|
|
737
746
|
function generateAnalyticsView(name, model, lower, plural, api, classified, lifecycle, view, modelDef) {
|
|
738
747
|
const numericAttrs = classified.business.filter((a) => ["integer", "number", "money", "decimal", "float"].includes(a.type.toLowerCase()));
|
|
739
748
|
const statusField = lifecycle?.statusField || "status";
|
|
749
|
+
const apiPath = api.replace(/^\/api/, "");
|
|
740
750
|
return `import { useState, useEffect } from 'react';
|
|
751
|
+
import { apiRequest } from '../lib/apiClient';
|
|
741
752
|
|
|
742
753
|
function ${name}() {
|
|
743
754
|
const [items, setItems] = useState<any[]>([]);
|
|
744
755
|
const [loading, setLoading] = useState(true);
|
|
745
756
|
|
|
746
757
|
useEffect(() => {
|
|
747
|
-
|
|
758
|
+
apiRequest('GET', '${apiPath}').then(data => {
|
|
748
759
|
setItems(Array.isArray(data) ? data : []);
|
|
749
760
|
setLoading(false);
|
|
750
761
|
}).catch(() => setLoading(false));
|
|
@@ -1,111 +1,81 @@
|
|
|
1
1
|
function generateReactHook(context) {
|
|
2
|
-
const { model
|
|
2
|
+
const { model } = context;
|
|
3
3
|
if (!model) {
|
|
4
4
|
throw new Error("Model is required in template context");
|
|
5
5
|
}
|
|
6
6
|
const modelName = model.name;
|
|
7
7
|
const hookName = `use${modelName}`;
|
|
8
8
|
const controllerName = `${modelName}Controller`;
|
|
9
|
+
const singular = modelName.charAt(0).toLowerCase() + modelName.slice(1);
|
|
10
|
+
const plural = singular + "s";
|
|
9
11
|
return `/**
|
|
10
12
|
* ${hookName}
|
|
11
|
-
*
|
|
13
|
+
*
|
|
14
|
+
* Thin delegator over the canonical useApi hooks. All state flows
|
|
15
|
+
* through \`['entities', '${modelName}']\` so WebSocket invalidation
|
|
16
|
+
* reaches every view consuming this hook.
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
19
|
+
import { useMemo } from 'react';
|
|
20
|
+
import {
|
|
21
|
+
useEntitiesQuery,
|
|
22
|
+
useExecuteOperationMutation,
|
|
23
|
+
} from './useApi';
|
|
16
24
|
import type { ${modelName} } from '../types/${modelName}';
|
|
17
25
|
|
|
18
|
-
interface
|
|
26
|
+
interface ${capitalize(hookName)}Options {
|
|
19
27
|
id?: string;
|
|
20
28
|
list?: boolean;
|
|
21
29
|
filters?: Record<string, any>;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*/
|
|
27
|
-
export function ${hookName}(options: Use${modelName}Options = {}) {
|
|
28
|
-
const queryClient = useQueryClient();
|
|
29
|
-
const { id, list, filters } = options;
|
|
30
|
-
|
|
31
|
-
// Fetch single ${modelName}
|
|
32
|
-
const { data: ${modelName.toLowerCase()}, isLoading: singleLoading, error: singleError } = useQuery({
|
|
33
|
-
queryKey: ['${modelName.toLowerCase()}', id],
|
|
34
|
-
queryFn: async () => {
|
|
35
|
-
if (!id) return null;
|
|
36
|
-
return await executeOperation('${controllerName}', 'retrieve', { id });
|
|
37
|
-
},
|
|
38
|
-
enabled: !!id && !list,
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
// Fetch list of ${modelName}s
|
|
42
|
-
const { data: ${modelName.toLowerCase()}s, isLoading: listLoading, error: listError } = useQuery({
|
|
43
|
-
queryKey: ['${modelName.toLowerCase()}s', filters],
|
|
44
|
-
queryFn: async () => {
|
|
45
|
-
return await executeOperation('${controllerName}', 'list', filters || {});
|
|
46
|
-
},
|
|
47
|
-
enabled: list,
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
const isLoading = list ? listLoading : singleLoading;
|
|
51
|
-
const error = list ? listError : singleError;
|
|
52
|
-
|
|
53
|
-
// Create mutation
|
|
54
|
-
const createMutation = useMutation({
|
|
55
|
-
mutationFn: async (data: Partial<${modelName}>) => {
|
|
56
|
-
return await executeOperation('${controllerName}', 'create', data);
|
|
57
|
-
},
|
|
58
|
-
onSuccess: () => {
|
|
59
|
-
queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}s'] });
|
|
60
|
-
},
|
|
61
|
-
});
|
|
32
|
+
export function ${hookName}(opts: ${capitalize(hookName)}Options = {}) {
|
|
33
|
+
const { id, filters: _filters } = opts;
|
|
62
34
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
onSuccess: (_, variables) => {
|
|
69
|
-
queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}', variables.id] });
|
|
70
|
-
queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}s'] });
|
|
71
|
-
},
|
|
72
|
-
});
|
|
35
|
+
// Canonical list query \u2014 one cache key per model. The list flag
|
|
36
|
+
// is preserved for backwards compatibility but the query always
|
|
37
|
+
// runs; React Query dedupes across consumers automatically.
|
|
38
|
+
const query = useEntitiesQuery('${controllerName}', '${modelName}');
|
|
39
|
+
const ${plural} = (query.data as ${modelName}[] | undefined) || [];
|
|
73
40
|
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// Refetch functions
|
|
85
|
-
const refetch = () => {
|
|
86
|
-
if (list) {
|
|
87
|
-
queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}s'] });
|
|
88
|
-
} else if (id) {
|
|
89
|
-
queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}', id] });
|
|
41
|
+
// Single-entity lookup is client-side against the canonical list
|
|
42
|
+
// so both the detail view and the list view share one source of
|
|
43
|
+
// truth. If the caller needs server-side hydration for a single
|
|
44
|
+
// entity, they should call useEntitiesQuery directly with a
|
|
45
|
+
// filtered controller endpoint.
|
|
46
|
+
const ${singular} = useMemo<${modelName} | null>(() => {
|
|
47
|
+
if (!id) return null;
|
|
48
|
+
for (const entity of ${plural}) {
|
|
49
|
+
const entityId = (entity as any)?.id || (entity as any)?.data?.id;
|
|
50
|
+
if (entityId === id) return entity;
|
|
90
51
|
}
|
|
91
|
-
|
|
52
|
+
return null;
|
|
53
|
+
}, [id, ${plural}]);
|
|
54
|
+
|
|
55
|
+
const mutation = useExecuteOperationMutation();
|
|
92
56
|
|
|
93
57
|
return {
|
|
94
|
-
${
|
|
95
|
-
${
|
|
96
|
-
isLoading,
|
|
97
|
-
error,
|
|
98
|
-
refetch,
|
|
99
|
-
create:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
58
|
+
${singular},
|
|
59
|
+
${plural},
|
|
60
|
+
isLoading: query.isLoading,
|
|
61
|
+
error: query.error,
|
|
62
|
+
refetch: () => { /* handled by useEntitiesQuery refetchInterval + WS invalidation */ },
|
|
63
|
+
create: (data: Partial<${modelName}>) =>
|
|
64
|
+
mutation.mutateAsync({ controllerName: '${controllerName}', operationName: 'create', data: data as Record<string, unknown> }),
|
|
65
|
+
update: (args: { id: string; data: Partial<${modelName}> }) =>
|
|
66
|
+
mutation.mutateAsync({ controllerName: '${controllerName}', operationName: 'update', entityId: args.id, data: args.data as Record<string, unknown> }),
|
|
67
|
+
delete: (entityId: string) =>
|
|
68
|
+
mutation.mutateAsync({ controllerName: '${controllerName}', operationName: 'delete', entityId, data: {} }),
|
|
69
|
+
isCreating: mutation.isPending,
|
|
70
|
+
isUpdating: mutation.isPending,
|
|
71
|
+
isDeleting: mutation.isPending,
|
|
105
72
|
};
|
|
106
73
|
}
|
|
107
74
|
`;
|
|
108
75
|
}
|
|
76
|
+
function capitalize(s) {
|
|
77
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
78
|
+
}
|
|
109
79
|
export {
|
|
110
80
|
generateReactHook as default
|
|
111
81
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/realize/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,cAAc,kBAAkB,CAAC;AAGjC,cAAc,kBAAkB,CAAC;AAGjC,cAAc,uBAAuB,CAAC;AAGtC,OAAO,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AACpF,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAGvD,OAAO,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAMlE,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AASnF,cAAM,sBAAuB,YAAW,aAAa;IACnD,IAAI,SAAa;IACjB,OAAO,SAAW;IAClB,YAAY,WAA+E;IAE3F,OAAO,CAAC,OAAO,CAAa;IAC5B,OAAO,CAAC,QAAQ,CAAa;IAC7B,OAAO,CAAC,aAAa,CAAa;IAClC,OAAO,CAAC,QAAQ,CAAa;IAC7B,OAAO,CAAC,WAAW,CAAS;IAEtB,UAAU,CAAC,MAAM,CAAC,EAAE;QACxB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBjB,OAAO,IAAI,UAAU;IAIrB,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,GAAG;IAK1B,QAAQ,CAAC,QAAQ,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,GAAG,OAAO,CAAC,eAAe,CAAC;IAKvF;;;OAGG;IACG,UAAU,CAAC,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/realize/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,cAAc,kBAAkB,CAAC;AAGjC,cAAc,kBAAkB,CAAC;AAGjC,cAAc,uBAAuB,CAAC;AAGtC,OAAO,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AACpF,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAGvD,OAAO,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAMlE,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AASnF,cAAM,sBAAuB,YAAW,aAAa;IACnD,IAAI,SAAa;IACjB,OAAO,SAAW;IAClB,YAAY,WAA+E;IAE3F,OAAO,CAAC,OAAO,CAAa;IAC5B,OAAO,CAAC,QAAQ,CAAa;IAC7B,OAAO,CAAC,aAAa,CAAa;IAClC,OAAO,CAAC,QAAQ,CAAa;IAC7B,OAAO,CAAC,WAAW,CAAS;IAEtB,UAAU,CAAC,MAAM,CAAC,EAAE;QACxB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBjB,OAAO,IAAI,UAAU;IAIrB,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,GAAG;IAK1B,QAAQ,CAAC,QAAQ,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,GAAG,OAAO,CAAC,eAAe,CAAC;IAKvF;;;OAGG;IACG,UAAU,CAAC,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAoyB9F;;;OAGG;YACW,UAAU;IAwDxB,OAAO,CAAC,oBAAoB;CAuB7B;AAED,eAAO,MAAM,MAAM,wBAA+B,CAAC;AACnD,eAAe,MAAM,CAAC;AACtB,OAAO,EAAE,sBAAsB,EAAE,CAAC"}
|
package/dist/realize/index.js
CHANGED
|
@@ -665,6 +665,210 @@ export default {
|
|
|
665
665
|
catch (e) {
|
|
666
666
|
errors.push(`Assets: ${e.message}`);
|
|
667
667
|
}
|
|
668
|
+
// 13. UI contract tests (Phase 1 of the inference test framework).
|
|
669
|
+
// Rules live in engines/src/inference/ui-contracts/. Each rule is
|
|
670
|
+
// a pure function (spec) => TestCase[]; the translator renders each
|
|
671
|
+
// rule's output into a single .spec.ts file under
|
|
672
|
+
// generated/code/tests/contract/. A Playwright config and the
|
|
673
|
+
// test:contract npm script are emitted alongside.
|
|
674
|
+
try {
|
|
675
|
+
const { generateContractTests, normalizeSpec } = await import('../inference/ui-contracts/index.js');
|
|
676
|
+
const normalized = normalizeSpec(spec);
|
|
677
|
+
const contractFiles = generateContractTests(normalized);
|
|
678
|
+
if (contractFiles.length > 0) {
|
|
679
|
+
const contractDir = join(outputDir, 'tests', 'contract');
|
|
680
|
+
if (!existsSync(contractDir))
|
|
681
|
+
mkdirSync(contractDir, { recursive: true });
|
|
682
|
+
let totalTests = 0;
|
|
683
|
+
for (const f of contractFiles) {
|
|
684
|
+
writeFileSync(join(contractDir, f.filename), f.contents, 'utf-8');
|
|
685
|
+
totalTests += f.testCount;
|
|
686
|
+
}
|
|
687
|
+
// Playwright config — written once at the output root. Uses
|
|
688
|
+
// the tests/contract directory exclusively; behavioral tests
|
|
689
|
+
// (if any) live under tests/e2e/ and are handled by a
|
|
690
|
+
// separate playwright.behavioral.config.ts (not auto-generated).
|
|
691
|
+
const playwrightConfigPath = join(outputDir, 'playwright.contract.config.ts');
|
|
692
|
+
const playwrightConfig = `/**
|
|
693
|
+
* Playwright config for auto-generated UI contract tests.
|
|
694
|
+
* DO NOT EDIT — regenerated every \`spv realize\` run.
|
|
695
|
+
*
|
|
696
|
+
* Behavioral tests (hand-written) live under tests/e2e/ and use a
|
|
697
|
+
* separate playwright.config.ts that you maintain yourself.
|
|
698
|
+
*/
|
|
699
|
+
import { defineConfig } from '@playwright/test';
|
|
700
|
+
|
|
701
|
+
export default defineConfig({
|
|
702
|
+
testDir: './tests/contract',
|
|
703
|
+
timeout: 30_000,
|
|
704
|
+
expect: { timeout: 10_000 },
|
|
705
|
+
reporter: 'list',
|
|
706
|
+
use: {
|
|
707
|
+
baseURL: process.env.SPECVERSE_FRONTEND_URL || 'http://localhost:5173',
|
|
708
|
+
trace: 'on-first-retry',
|
|
709
|
+
},
|
|
710
|
+
projects: [
|
|
711
|
+
{
|
|
712
|
+
name: 'chromium',
|
|
713
|
+
use: { browserName: 'chromium' },
|
|
714
|
+
},
|
|
715
|
+
],
|
|
716
|
+
});
|
|
717
|
+
`;
|
|
718
|
+
writeFileSync(playwrightConfigPath, playwrightConfig, 'utf-8');
|
|
719
|
+
console.log(` ✅ UI contract tests: ${contractFiles.length} file(s), ${totalTests} test case(s)`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
catch (e) {
|
|
723
|
+
errors.push(`UI contract tests: ${e.message}`);
|
|
724
|
+
}
|
|
725
|
+
// 14. Implementation conformance checks (Phase 5 of the inference
|
|
726
|
+
// test framework). Static, grep-level checks that verify the
|
|
727
|
+
// current React-Query-based implementation honors the design
|
|
728
|
+
// contract. Runs in milliseconds, no browser needed. When the
|
|
729
|
+
// runtime is rewritten in a different state library, this script
|
|
730
|
+
// gets rewritten too — the design-contract Playwright tests above
|
|
731
|
+
// are the stable layer, these checks are the short-lived ratchet
|
|
732
|
+
// that enforces "no escape hatches in the current implementation".
|
|
733
|
+
try {
|
|
734
|
+
const scriptsDir = join(outputDir, 'scripts');
|
|
735
|
+
if (!existsSync(scriptsDir))
|
|
736
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
737
|
+
const conformanceScript = `#!/usr/bin/env node
|
|
738
|
+
/**
|
|
739
|
+
* Implementation conformance checks — auto-generated.
|
|
740
|
+
* DO NOT EDIT — regenerated every \`spv realize\` run.
|
|
741
|
+
*
|
|
742
|
+
* Verifies the current React-Query-based runtime implementation
|
|
743
|
+
* doesn't have escape hatches that could violate the design
|
|
744
|
+
* contract:
|
|
745
|
+
* 1. no-raw-fetch-in-views — view components go through apiClient
|
|
746
|
+
* 2. no-bare-usequery-in-views — view components use named hooks
|
|
747
|
+
* 3. websocket-invalidates-cache — WS events trigger cache refetch
|
|
748
|
+
* 4. mutation-hooks-invalidate — mutations invalidate after success
|
|
749
|
+
*
|
|
750
|
+
* Exits 1 on any failure. Fast (grep-level, no browser).
|
|
751
|
+
*/
|
|
752
|
+
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
753
|
+
import { join, dirname } from 'path';
|
|
754
|
+
import { fileURLToPath } from 'url';
|
|
755
|
+
|
|
756
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
757
|
+
const root = join(__dirname, '..');
|
|
758
|
+
let failed = false;
|
|
759
|
+
|
|
760
|
+
function walk(dir, pattern, exclude = []) {
|
|
761
|
+
const hits = [];
|
|
762
|
+
const visit = (d) => {
|
|
763
|
+
let stat;
|
|
764
|
+
try { stat = statSync(d); } catch { return; }
|
|
765
|
+
if (!stat.isDirectory()) return;
|
|
766
|
+
for (const entry of readdirSync(d)) {
|
|
767
|
+
const p = join(d, entry);
|
|
768
|
+
if (exclude.some(e => p.includes(e))) continue;
|
|
769
|
+
let s;
|
|
770
|
+
try { s = statSync(p); } catch { continue; }
|
|
771
|
+
if (s.isDirectory()) visit(p);
|
|
772
|
+
else if (/\\.(ts|tsx|js|jsx|mjs)$/.test(entry)) {
|
|
773
|
+
const lines = readFileSync(p, 'utf-8').split('\\n');
|
|
774
|
+
lines.forEach((line, i) => {
|
|
775
|
+
if (pattern.test(line)) hits.push({ file: p, line: i + 1, text: line.trim() });
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
visit(dir);
|
|
781
|
+
return hits;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function report(name, ok, msg, hits) {
|
|
785
|
+
console.log(\`\${ok ? '\\u2713' : '\\u2717'} \${name}\${msg ? ' \\u2014 ' + msg : ''}\`);
|
|
786
|
+
if (!ok) {
|
|
787
|
+
failed = true;
|
|
788
|
+
if (hits) {
|
|
789
|
+
for (const h of hits.slice(0, 5)) {
|
|
790
|
+
console.log(\` \${h.file}:\${h.line} \${h.text.slice(0, 120)}\`);
|
|
791
|
+
}
|
|
792
|
+
if (hits.length > 5) console.log(\` ... and \${hits.length - 5} more\`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const frontendSrc = join(root, 'frontend', 'src');
|
|
798
|
+
const componentsDir = join(frontendSrc, 'components');
|
|
799
|
+
const pagesDir = join(frontendSrc, 'pages');
|
|
800
|
+
|
|
801
|
+
// 1. no-raw-fetch-in-views
|
|
802
|
+
{
|
|
803
|
+
const hits = [
|
|
804
|
+
...walk(componentsDir, /\\bfetch\\s*\\(/),
|
|
805
|
+
...walk(pagesDir, /\\bfetch\\s*\\(/),
|
|
806
|
+
];
|
|
807
|
+
report(
|
|
808
|
+
'no-raw-fetch-in-views',
|
|
809
|
+
hits.length === 0,
|
|
810
|
+
hits.length === 0 ? '' : \`found \${hits.length} raw fetch() call(s) outside apiClient\`,
|
|
811
|
+
hits
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// 2. no-bare-usequery-in-views — allow useQuery only inside hooks/useApi
|
|
816
|
+
{
|
|
817
|
+
const hits = walk(frontendSrc, /\\buseQuery\\s*\\(/, ['hooks/useApi', 'lib/']);
|
|
818
|
+
report(
|
|
819
|
+
'no-bare-usequery-in-views',
|
|
820
|
+
hits.length === 0,
|
|
821
|
+
hits.length === 0 ? '' : \`found \${hits.length} bare useQuery() call(s) outside useApi\`,
|
|
822
|
+
hits
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// 3. websocket-invalidates-cache — runtime must wire WS → invalidate
|
|
827
|
+
{
|
|
828
|
+
const candidates = [
|
|
829
|
+
join(root, 'node_modules', '@specverse', 'runtime', 'dist', 'runtime', 'views', 'react', 'hooks', 'useEntitySync.js'),
|
|
830
|
+
];
|
|
831
|
+
let content = null;
|
|
832
|
+
for (const path of candidates) {
|
|
833
|
+
try { content = readFileSync(path, 'utf-8'); break; } catch { /* keep trying */ }
|
|
834
|
+
}
|
|
835
|
+
if (!content) {
|
|
836
|
+
report('websocket-invalidates-cache', false, 'useEntitySync hook not found in @specverse/runtime');
|
|
837
|
+
} else if (!/invalidateEntities/.test(content)) {
|
|
838
|
+
report('websocket-invalidates-cache', false, 'useEntitySync does not call invalidateEntities on event receipt');
|
|
839
|
+
} else {
|
|
840
|
+
report('websocket-invalidates-cache', true, '');
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// 4. mutation-hooks-invalidate — useApi.ts must invalidate on success
|
|
845
|
+
{
|
|
846
|
+
const useApiPath = join(frontendSrc, 'hooks', 'useApi.ts');
|
|
847
|
+
let content = null;
|
|
848
|
+
try { content = readFileSync(useApiPath, 'utf-8'); } catch { /* ignore */ }
|
|
849
|
+
if (!content) {
|
|
850
|
+
report('mutation-hooks-invalidate', false, 'frontend/src/hooks/useApi.ts not found');
|
|
851
|
+
} else if (!/useExecuteOperationMutation/.test(content)) {
|
|
852
|
+
report('mutation-hooks-invalidate', false, 'useExecuteOperationMutation not exported from useApi.ts');
|
|
853
|
+
} else if (!/invalidateQueries/.test(content)) {
|
|
854
|
+
report('mutation-hooks-invalidate', false, 'useApi.ts does not call invalidateQueries on mutation success');
|
|
855
|
+
} else {
|
|
856
|
+
report('mutation-hooks-invalidate', true, '');
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (failed) {
|
|
861
|
+
console.log('\\n\\u2717 conformance checks failed');
|
|
862
|
+
process.exit(1);
|
|
863
|
+
}
|
|
864
|
+
console.log('\\n\\u2713 all conformance checks passed');
|
|
865
|
+
`;
|
|
866
|
+
writeFileSync(join(scriptsDir, 'test-conformance.mjs'), conformanceScript, 'utf-8');
|
|
867
|
+
console.log(` ✅ UI contract conformance: scripts/test-conformance.mjs`);
|
|
868
|
+
}
|
|
869
|
+
catch (e) {
|
|
870
|
+
errors.push(`UI conformance: ${e.message}`);
|
|
871
|
+
}
|
|
668
872
|
console.log(`\n✅ All code generated in: ${outputDir}`);
|
|
669
873
|
if (errors.length) {
|
|
670
874
|
console.warn(`⚠️ ${errors.length} warning(s) during generation`);
|