@specverse/engines 4.1.22 → 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 +5 -2
- 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 +5 -2
- 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
|
@@ -192,6 +192,7 @@ function pluralize(s: string): string {
|
|
|
192
192
|
function generateListView(name: string, model: string, lower: string, plural: string, api: string, classified: ClassifiedAttrs, belongsTo: Rel[], lifecycle: Lifecycle | null, view: any): string {
|
|
193
193
|
const displayCols = classified.business.slice(0, 5);
|
|
194
194
|
const statusField = lifecycle?.statusField || classified.lifecycle[0]?.name;
|
|
195
|
+
const apiPath = api.replace(/^\/api/, ''); // strip /api prefix for apiRequest
|
|
195
196
|
|
|
196
197
|
// Build relationship lookup fetches
|
|
197
198
|
const relFetches = belongsTo.map(r => {
|
|
@@ -201,7 +202,7 @@ function generateListView(name: string, model: string, lower: string, plural: st
|
|
|
201
202
|
|
|
202
203
|
const relEffects = belongsTo.map(r => {
|
|
203
204
|
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
204
|
-
return `
|
|
205
|
+
return ` apiRequest('GET', '/${tLower}s').then(data => {
|
|
205
206
|
if (Array.isArray(data)) {
|
|
206
207
|
const m: Record<string, any> = {};
|
|
207
208
|
data.forEach(e => { m[e.id] = e; });
|
|
@@ -218,6 +219,7 @@ function generateListView(name: string, model: string, lower: string, plural: st
|
|
|
218
219
|
|
|
219
220
|
return `import { useState, useEffect } from 'react';
|
|
220
221
|
import { Link } from 'react-router-dom';
|
|
222
|
+
import { apiRequest } from '../lib/apiClient';
|
|
221
223
|
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
222
224
|
import { formatValue, formatDate, StatusBadge } from '../lib/view-helpers';
|
|
223
225
|
|
|
@@ -227,7 +229,7 @@ function ${name}() {
|
|
|
227
229
|
${relFetches}
|
|
228
230
|
|
|
229
231
|
useEffect(() => {
|
|
230
|
-
|
|
232
|
+
apiRequest('GET', '${apiPath}').then(data => {
|
|
231
233
|
setItems(Array.isArray(data) ? data : []);
|
|
232
234
|
setLoading(false);
|
|
233
235
|
}).catch(() => setLoading(false));
|
|
@@ -295,6 +297,7 @@ export default ${name};
|
|
|
295
297
|
|
|
296
298
|
function generateDetailView(name: string, model: string, lower: string, plural: string, api: string, classified: ClassifiedAttrs, belongsTo: Rel[], hasMany: Rel[], lifecycle: Lifecycle | null, view: any): string {
|
|
297
299
|
const statusField = lifecycle?.statusField || classified.lifecycle[0]?.name;
|
|
300
|
+
const apiPath = api.replace(/^\/api/, '');
|
|
298
301
|
|
|
299
302
|
const relFetches = belongsTo.map(r => {
|
|
300
303
|
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
@@ -304,7 +307,7 @@ function generateDetailView(name: string, model: string, lower: string, plural:
|
|
|
304
307
|
const relEffects = belongsTo.map(r => {
|
|
305
308
|
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
306
309
|
const fk = `${r.name}_id`;
|
|
307
|
-
return ` if (data.${fk})
|
|
310
|
+
return ` if (data.${fk}) apiRequest('GET', \`/${tLower}s/\${data.${fk}}\`).then(d => set${r.target}Ref(d)).catch(() => {});`;
|
|
308
311
|
}).join('\n');
|
|
309
312
|
|
|
310
313
|
const hasManyFetches = hasMany.map(r => {
|
|
@@ -314,11 +317,12 @@ function generateDetailView(name: string, model: string, lower: string, plural:
|
|
|
314
317
|
|
|
315
318
|
const hasManyEffects = hasMany.map(r => {
|
|
316
319
|
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
317
|
-
return `
|
|
320
|
+
return ` apiRequest('GET', \`/${tLower}s?${lower}Id=\${data.id}\`).then(d => set${capitalize(r.name)}Items(Array.isArray(d) ? d : [])).catch(() => {});`;
|
|
318
321
|
}).join('\n');
|
|
319
322
|
|
|
320
323
|
return `import { useState, useEffect } from 'react';
|
|
321
324
|
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
|
325
|
+
import { apiRequest } from '../lib/apiClient';
|
|
322
326
|
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
323
327
|
import { formatValue, formatDate, StatusBadge } from '../lib/view-helpers';
|
|
324
328
|
|
|
@@ -333,7 +337,7 @@ ${hasManyFetches}
|
|
|
333
337
|
|
|
334
338
|
useEffect(() => {
|
|
335
339
|
if (!id) { setLoading(false); return; }
|
|
336
|
-
|
|
340
|
+
apiRequest('GET', \`${apiPath}/\${id}\`).then(data => {
|
|
337
341
|
setItem(data);
|
|
338
342
|
setLoading(false);
|
|
339
343
|
if (data) {
|
|
@@ -348,7 +352,7 @@ ${hasManyEffects}
|
|
|
348
352
|
|
|
349
353
|
const handleDelete = async () => {
|
|
350
354
|
if (!confirm('Delete this ${lower}?')) return;
|
|
351
|
-
await
|
|
355
|
+
await apiRequest('DELETE', \`${apiPath}/\${id}\`);
|
|
352
356
|
navigate('/${lower}list');
|
|
353
357
|
};
|
|
354
358
|
|
|
@@ -436,6 +440,7 @@ function generateFormView(name: string, model: string, lower: string, plural: st
|
|
|
436
440
|
// Only show editable fields (business + lifecycle, not metadata/auto)
|
|
437
441
|
const editableFields = [...classified.business, ...classified.lifecycle];
|
|
438
442
|
const statusField = lifecycle?.statusField;
|
|
443
|
+
const apiPath = api.replace(/^\/api/, '');
|
|
439
444
|
|
|
440
445
|
const relStates = belongsTo.map(r => {
|
|
441
446
|
return ` const [${r.target.charAt(0).toLowerCase() + r.target.slice(1)}Options, set${r.target}Options] = useState<any[]>([]);`;
|
|
@@ -443,7 +448,7 @@ function generateFormView(name: string, model: string, lower: string, plural: st
|
|
|
443
448
|
|
|
444
449
|
const relEffects = belongsTo.map(r => {
|
|
445
450
|
const tLower = r.target.charAt(0).toLowerCase() + r.target.slice(1);
|
|
446
|
-
return `
|
|
451
|
+
return ` apiRequest('GET', '/${tLower}s').then(d => set${r.target}Options(Array.isArray(d) ? d : [])).catch(() => {});`;
|
|
447
452
|
}).join('\n');
|
|
448
453
|
|
|
449
454
|
function inputForField(attr: Attr): string {
|
|
@@ -477,6 +482,7 @@ ${lifecycle.states.map(s => ` <option value="${s}">${s.replace(/[_-]/g,
|
|
|
477
482
|
|
|
478
483
|
return `import { useState, useEffect } from 'react';
|
|
479
484
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
|
485
|
+
import { apiRequest } from '../lib/apiClient';
|
|
480
486
|
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
481
487
|
|
|
482
488
|
function ${name}() {
|
|
@@ -490,7 +496,7 @@ ${relStates}
|
|
|
490
496
|
|
|
491
497
|
useEffect(() => {
|
|
492
498
|
if (editId) {
|
|
493
|
-
|
|
499
|
+
apiRequest('GET', \`${apiPath}/\${editId}\`).then(data => setForm(data || {})).catch(() => {});
|
|
494
500
|
}
|
|
495
501
|
${relEffects}
|
|
496
502
|
}, [editId]);
|
|
@@ -505,17 +511,12 @@ ${relEffects}
|
|
|
505
511
|
setSaving(true);
|
|
506
512
|
setError('');
|
|
507
513
|
try {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
headers: { 'Content-Type': 'application/json' },
|
|
513
|
-
body: JSON.stringify(form),
|
|
514
|
-
});
|
|
515
|
-
if (!res.ok) {
|
|
516
|
-
const err = await res.json().catch(() => ({}));
|
|
517
|
-
throw new Error(err.message || 'Save failed');
|
|
514
|
+
if (editId) {
|
|
515
|
+
await apiRequest('PUT', \`${apiPath}/\${editId}\`, form);
|
|
516
|
+
} else {
|
|
517
|
+
await apiRequest('POST', '${apiPath}', form);
|
|
518
518
|
}
|
|
519
|
+
// apiRequest throws on non-2xx so reaching here means success.
|
|
519
520
|
navigate('/${lower}list');
|
|
520
521
|
} catch (err: any) {
|
|
521
522
|
setError(err.message);
|
|
@@ -582,8 +583,10 @@ export default ${name};
|
|
|
582
583
|
function generateDashboardView(name: string, model: string, lower: string, plural: string, api: string, classified: ClassifiedAttrs, view: any, modelDef: any): string {
|
|
583
584
|
const numericAttrs = classified.business.filter(a => ['integer', 'number', 'money', 'decimal', 'float'].includes(a.type.toLowerCase()));
|
|
584
585
|
const metricNames = numericAttrs.map(a => a.name).slice(0, 4);
|
|
586
|
+
const apiPath = api.replace(/^\/api/, '');
|
|
585
587
|
|
|
586
588
|
return `import { useState, useEffect } from 'react';
|
|
589
|
+
import { apiRequest } from '../lib/apiClient';
|
|
587
590
|
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
588
591
|
import { formatDate, StatusBadge } from '../lib/view-helpers';
|
|
589
592
|
|
|
@@ -592,7 +595,7 @@ function ${name}() {
|
|
|
592
595
|
const [loading, setLoading] = useState(true);
|
|
593
596
|
|
|
594
597
|
useEffect(() => {
|
|
595
|
-
|
|
598
|
+
apiRequest('GET', '${apiPath}').then(data => {
|
|
596
599
|
setItems(Array.isArray(data) ? data : []);
|
|
597
600
|
setLoading(false);
|
|
598
601
|
}).catch(() => setLoading(false));
|
|
@@ -650,9 +653,11 @@ export default ${name};
|
|
|
650
653
|
function generateBoardView(name: string, model: string, lower: string, plural: string, api: string, lifecycle: Lifecycle | null, view: any): string {
|
|
651
654
|
const states = lifecycle?.states || ['todo', 'in_progress', 'done'];
|
|
652
655
|
const statusField = lifecycle?.statusField || 'status';
|
|
656
|
+
const apiPath = api.replace(/^\/api/, '');
|
|
653
657
|
|
|
654
658
|
return `import { useState, useEffect } from 'react';
|
|
655
659
|
import { Link } from 'react-router-dom';
|
|
660
|
+
import { apiRequest } from '../lib/apiClient';
|
|
656
661
|
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
657
662
|
import { statusColor } from '../lib/view-helpers';
|
|
658
663
|
|
|
@@ -661,7 +666,7 @@ function ${name}() {
|
|
|
661
666
|
const [loading, setLoading] = useState(true);
|
|
662
667
|
|
|
663
668
|
useEffect(() => {
|
|
664
|
-
|
|
669
|
+
apiRequest('GET', '${apiPath}').then(data => {
|
|
665
670
|
setItems(Array.isArray(data) ? data : []);
|
|
666
671
|
setLoading(false);
|
|
667
672
|
}).catch(() => setLoading(false));
|
|
@@ -718,8 +723,10 @@ export default ${name};
|
|
|
718
723
|
// ============================================================================
|
|
719
724
|
|
|
720
725
|
function generateTimelineView(name: string, model: string, lower: string, plural: string, api: string, view: any): string {
|
|
726
|
+
const apiPath = api.replace(/^\/api/, '');
|
|
721
727
|
return `import { useState, useEffect } from 'react';
|
|
722
728
|
import { Link } from 'react-router-dom';
|
|
729
|
+
import { apiRequest } from '../lib/apiClient';
|
|
723
730
|
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
724
731
|
import { formatDate, StatusBadge } from '../lib/view-helpers';
|
|
725
732
|
|
|
@@ -728,7 +735,7 @@ function ${name}() {
|
|
|
728
735
|
const [loading, setLoading] = useState(true);
|
|
729
736
|
|
|
730
737
|
useEffect(() => {
|
|
731
|
-
|
|
738
|
+
apiRequest('GET', '${apiPath}').then(data => {
|
|
732
739
|
const sorted = (Array.isArray(data) ? data : []).sort((a, b) =>
|
|
733
740
|
new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime()
|
|
734
741
|
);
|
|
@@ -778,8 +785,10 @@ function generateCalendarView(name: string, model: string, lower: string, plural
|
|
|
778
785
|
const dateField = attrs.find(a =>
|
|
779
786
|
['startdate', 'duedate', 'scheduledat', 'eventdate', 'date'].includes(a.name.toLowerCase())
|
|
780
787
|
)?.name || 'createdAt';
|
|
788
|
+
const apiPath = api.replace(/^\/api/, '');
|
|
781
789
|
|
|
782
790
|
return `import { useState, useEffect } from 'react';
|
|
791
|
+
import { apiRequest } from '../lib/apiClient';
|
|
783
792
|
import { getEntityDisplayName } from '../lib/field-helpers';
|
|
784
793
|
|
|
785
794
|
function ${name}() {
|
|
@@ -788,7 +797,7 @@ function ${name}() {
|
|
|
788
797
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
|
789
798
|
|
|
790
799
|
useEffect(() => {
|
|
791
|
-
|
|
800
|
+
apiRequest('GET', '${apiPath}').then(data => {
|
|
792
801
|
setItems(Array.isArray(data) ? data : []);
|
|
793
802
|
setLoading(false);
|
|
794
803
|
}).catch(() => setLoading(false));
|
|
@@ -852,15 +861,17 @@ export default ${name};
|
|
|
852
861
|
function generateAnalyticsView(name: string, model: string, lower: string, plural: string, api: string, classified: ClassifiedAttrs, lifecycle: Lifecycle | null, view: any, modelDef: any): string {
|
|
853
862
|
const numericAttrs = classified.business.filter(a => ['integer', 'number', 'money', 'decimal', 'float'].includes(a.type.toLowerCase()));
|
|
854
863
|
const statusField = lifecycle?.statusField || 'status';
|
|
864
|
+
const apiPath = api.replace(/^\/api/, '');
|
|
855
865
|
|
|
856
866
|
return `import { useState, useEffect } from 'react';
|
|
867
|
+
import { apiRequest } from '../lib/apiClient';
|
|
857
868
|
|
|
858
869
|
function ${name}() {
|
|
859
870
|
const [items, setItems] = useState<any[]>([]);
|
|
860
871
|
const [loading, setLoading] = useState(true);
|
|
861
872
|
|
|
862
873
|
useEffect(() => {
|
|
863
|
-
|
|
874
|
+
apiRequest('GET', '${apiPath}').then(data => {
|
|
864
875
|
setItems(Array.isArray(data) ? data : []);
|
|
865
876
|
setLoading(false);
|
|
866
877
|
}).catch(() => setLoading(false));
|
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* React Hooks Generator
|
|
2
|
+
* React Hooks Generator — per-model convenience hook.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Emits `use${Model}()` which is a thin delegator over the canonical
|
|
5
|
+
* hooks in `hooks/useApi.ts`. Historically this generator produced
|
|
6
|
+
* its own `useQuery(...)` + `useMutation(...)` blocks with private
|
|
7
|
+
* query keys (`['poll', id]`, `['polls', filters]`), which meant:
|
|
8
|
+
*
|
|
9
|
+
* 1. WebSocket events invalidating `['entities', modelName]` never
|
|
10
|
+
* reached views that consumed this hook.
|
|
11
|
+
* 2. The conformance check `no-bare-usequery-in-views` lit up for
|
|
12
|
+
* every per-model hook file.
|
|
13
|
+
*
|
|
14
|
+
* v2: delegate everything to `useEntitiesQuery` and
|
|
15
|
+
* `useExecuteOperationMutation` so there's exactly one cache key
|
|
16
|
+
* per model and one mutation pipeline across the whole frontend.
|
|
17
|
+
*
|
|
18
|
+
* The public API of `use${Model}()` is preserved for callers that
|
|
19
|
+
* depend on it: { [model], [models], isLoading, error, refetch,
|
|
20
|
+
* create, update, delete, isCreating, isUpdating, isDeleting }.
|
|
5
21
|
*/
|
|
6
22
|
|
|
7
23
|
import type { TemplateContext } from '@specverse/types';
|
|
8
24
|
|
|
9
|
-
/**
|
|
10
|
-
* Generate React custom hook for a model
|
|
11
|
-
*/
|
|
12
25
|
export default function generateReactHook(context: TemplateContext): string {
|
|
13
|
-
const { model
|
|
26
|
+
const { model } = context;
|
|
14
27
|
|
|
15
28
|
if (!model) {
|
|
16
29
|
throw new Error('Model is required in template context');
|
|
@@ -19,104 +32,75 @@ export default function generateReactHook(context: TemplateContext): string {
|
|
|
19
32
|
const modelName = model.name;
|
|
20
33
|
const hookName = `use${modelName}`;
|
|
21
34
|
const controllerName = `${modelName}Controller`;
|
|
35
|
+
const singular = modelName.charAt(0).toLowerCase() + modelName.slice(1);
|
|
36
|
+
const plural = singular + 's';
|
|
22
37
|
|
|
23
38
|
return `/**
|
|
24
39
|
* ${hookName}
|
|
25
|
-
*
|
|
40
|
+
*
|
|
41
|
+
* Thin delegator over the canonical useApi hooks. All state flows
|
|
42
|
+
* through \`['entities', '${modelName}']\` so WebSocket invalidation
|
|
43
|
+
* reaches every view consuming this hook.
|
|
26
44
|
*/
|
|
27
45
|
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
46
|
+
import { useMemo } from 'react';
|
|
47
|
+
import {
|
|
48
|
+
useEntitiesQuery,
|
|
49
|
+
useExecuteOperationMutation,
|
|
50
|
+
} from './useApi';
|
|
30
51
|
import type { ${modelName} } from '../types/${modelName}';
|
|
31
52
|
|
|
32
|
-
interface
|
|
53
|
+
interface ${capitalize(hookName)}Options {
|
|
33
54
|
id?: string;
|
|
34
55
|
list?: boolean;
|
|
35
56
|
filters?: Record<string, any>;
|
|
36
57
|
}
|
|
37
58
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
queryKey: ['${modelName.toLowerCase()}s', filters],
|
|
58
|
-
queryFn: async () => {
|
|
59
|
-
return await executeOperation('${controllerName}', 'list', filters || {});
|
|
60
|
-
},
|
|
61
|
-
enabled: list,
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
const isLoading = list ? listLoading : singleLoading;
|
|
65
|
-
const error = list ? listError : singleError;
|
|
66
|
-
|
|
67
|
-
// Create mutation
|
|
68
|
-
const createMutation = useMutation({
|
|
69
|
-
mutationFn: async (data: Partial<${modelName}>) => {
|
|
70
|
-
return await executeOperation('${controllerName}', 'create', data);
|
|
71
|
-
},
|
|
72
|
-
onSuccess: () => {
|
|
73
|
-
queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}s'] });
|
|
74
|
-
},
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
// Update mutation
|
|
78
|
-
const updateMutation = useMutation({
|
|
79
|
-
mutationFn: async ({ id, data }: { id: string; data: Partial<${modelName}> }) => {
|
|
80
|
-
return await executeOperation('${controllerName}', 'update', { id, ...data });
|
|
81
|
-
},
|
|
82
|
-
onSuccess: (_, variables) => {
|
|
83
|
-
queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}', variables.id] });
|
|
84
|
-
queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}s'] });
|
|
85
|
-
},
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// Delete mutation
|
|
89
|
-
const deleteMutation = useMutation({
|
|
90
|
-
mutationFn: async (id: string) => {
|
|
91
|
-
return await executeOperation('${controllerName}', 'delete', { id });
|
|
92
|
-
},
|
|
93
|
-
onSuccess: () => {
|
|
94
|
-
queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}s'] });
|
|
95
|
-
},
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
// Refetch functions
|
|
99
|
-
const refetch = () => {
|
|
100
|
-
if (list) {
|
|
101
|
-
queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}s'] });
|
|
102
|
-
} else if (id) {
|
|
103
|
-
queryClient.invalidateQueries({ queryKey: ['${modelName.toLowerCase()}', id] });
|
|
59
|
+
export function ${hookName}(opts: ${capitalize(hookName)}Options = {}) {
|
|
60
|
+
const { id, filters: _filters } = opts;
|
|
61
|
+
|
|
62
|
+
// Canonical list query — one cache key per model. The list flag
|
|
63
|
+
// is preserved for backwards compatibility but the query always
|
|
64
|
+
// runs; React Query dedupes across consumers automatically.
|
|
65
|
+
const query = useEntitiesQuery('${controllerName}', '${modelName}');
|
|
66
|
+
const ${plural} = (query.data as ${modelName}[] | undefined) || [];
|
|
67
|
+
|
|
68
|
+
// Single-entity lookup is client-side against the canonical list
|
|
69
|
+
// so both the detail view and the list view share one source of
|
|
70
|
+
// truth. If the caller needs server-side hydration for a single
|
|
71
|
+
// entity, they should call useEntitiesQuery directly with a
|
|
72
|
+
// filtered controller endpoint.
|
|
73
|
+
const ${singular} = useMemo<${modelName} | null>(() => {
|
|
74
|
+
if (!id) return null;
|
|
75
|
+
for (const entity of ${plural}) {
|
|
76
|
+
const entityId = (entity as any)?.id || (entity as any)?.data?.id;
|
|
77
|
+
if (entityId === id) return entity;
|
|
104
78
|
}
|
|
105
|
-
|
|
79
|
+
return null;
|
|
80
|
+
}, [id, ${plural}]);
|
|
81
|
+
|
|
82
|
+
const mutation = useExecuteOperationMutation();
|
|
106
83
|
|
|
107
84
|
return {
|
|
108
|
-
${
|
|
109
|
-
${
|
|
110
|
-
isLoading,
|
|
111
|
-
error,
|
|
112
|
-
refetch,
|
|
113
|
-
create:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
85
|
+
${singular},
|
|
86
|
+
${plural},
|
|
87
|
+
isLoading: query.isLoading,
|
|
88
|
+
error: query.error,
|
|
89
|
+
refetch: () => { /* handled by useEntitiesQuery refetchInterval + WS invalidation */ },
|
|
90
|
+
create: (data: Partial<${modelName}>) =>
|
|
91
|
+
mutation.mutateAsync({ controllerName: '${controllerName}', operationName: 'create', data: data as Record<string, unknown> }),
|
|
92
|
+
update: (args: { id: string; data: Partial<${modelName}> }) =>
|
|
93
|
+
mutation.mutateAsync({ controllerName: '${controllerName}', operationName: 'update', entityId: args.id, data: args.data as Record<string, unknown> }),
|
|
94
|
+
delete: (entityId: string) =>
|
|
95
|
+
mutation.mutateAsync({ controllerName: '${controllerName}', operationName: 'delete', entityId, data: {} }),
|
|
96
|
+
isCreating: mutation.isPending,
|
|
97
|
+
isUpdating: mutation.isPending,
|
|
98
|
+
isDeleting: mutation.isPending,
|
|
119
99
|
};
|
|
120
100
|
}
|
|
121
101
|
`;
|
|
122
102
|
}
|
|
103
|
+
|
|
104
|
+
function capitalize(s: string): string {
|
|
105
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
106
|
+
}
|