@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.
Files changed (72) hide show
  1. package/dist/inference/ui-contracts/index.d.ts +38 -0
  2. package/dist/inference/ui-contracts/index.d.ts.map +1 -0
  3. package/dist/inference/ui-contracts/index.js +212 -0
  4. package/dist/inference/ui-contracts/index.js.map +1 -0
  5. package/dist/inference/ui-contracts/rules/_shared.d.ts +32 -0
  6. package/dist/inference/ui-contracts/rules/_shared.d.ts.map +1 -0
  7. package/dist/inference/ui-contracts/rules/_shared.js +103 -0
  8. package/dist/inference/ui-contracts/rules/_shared.js.map +1 -0
  9. package/dist/inference/ui-contracts/rules/action-buttons-present.d.ts +21 -0
  10. package/dist/inference/ui-contracts/rules/action-buttons-present.d.ts.map +1 -0
  11. package/dist/inference/ui-contracts/rules/action-buttons-present.js +62 -0
  12. package/dist/inference/ui-contracts/rules/action-buttons-present.js.map +1 -0
  13. package/dist/inference/ui-contracts/rules/create-reflects-in-list.d.ts +22 -0
  14. package/dist/inference/ui-contracts/rules/create-reflects-in-list.d.ts.map +1 -0
  15. package/dist/inference/ui-contracts/rules/create-reflects-in-list.js +48 -0
  16. package/dist/inference/ui-contracts/rules/create-reflects-in-list.js.map +1 -0
  17. package/dist/inference/ui-contracts/rules/delete-reflects-in-list.d.ts +22 -0
  18. package/dist/inference/ui-contracts/rules/delete-reflects-in-list.d.ts.map +1 -0
  19. package/dist/inference/ui-contracts/rules/delete-reflects-in-list.js +50 -0
  20. package/dist/inference/ui-contracts/rules/delete-reflects-in-list.js.map +1 -0
  21. package/dist/inference/ui-contracts/rules/detail-view-renders.d.ts +24 -0
  22. package/dist/inference/ui-contracts/rules/detail-view-renders.d.ts.map +1 -0
  23. package/dist/inference/ui-contracts/rules/detail-view-renders.js +34 -0
  24. package/dist/inference/ui-contracts/rules/detail-view-renders.js.map +1 -0
  25. package/dist/inference/ui-contracts/rules/evolve-reflects-in-list.d.ts +21 -0
  26. package/dist/inference/ui-contracts/rules/evolve-reflects-in-list.d.ts.map +1 -0
  27. package/dist/inference/ui-contracts/rules/evolve-reflects-in-list.js +53 -0
  28. package/dist/inference/ui-contracts/rules/evolve-reflects-in-list.js.map +1 -0
  29. package/dist/inference/ui-contracts/rules/form-shows-required-indicators.d.ts +15 -0
  30. package/dist/inference/ui-contracts/rules/form-shows-required-indicators.d.ts.map +1 -0
  31. package/dist/inference/ui-contracts/rules/form-shows-required-indicators.js +38 -0
  32. package/dist/inference/ui-contracts/rules/form-shows-required-indicators.js.map +1 -0
  33. package/dist/inference/ui-contracts/rules/hasmany-shows-children-in-detail.d.ts +17 -0
  34. package/dist/inference/ui-contracts/rules/hasmany-shows-children-in-detail.d.ts.map +1 -0
  35. package/dist/inference/ui-contracts/rules/hasmany-shows-children-in-detail.js +39 -0
  36. package/dist/inference/ui-contracts/rules/hasmany-shows-children-in-detail.js.map +1 -0
  37. package/dist/inference/ui-contracts/rules/lifecycle-state-visible-in-detail.d.ts +25 -0
  38. package/dist/inference/ui-contracts/rules/lifecycle-state-visible-in-detail.d.ts.map +1 -0
  39. package/dist/inference/ui-contracts/rules/lifecycle-state-visible-in-detail.js +66 -0
  40. package/dist/inference/ui-contracts/rules/lifecycle-state-visible-in-detail.js.map +1 -0
  41. package/dist/inference/ui-contracts/rules/list-shows-business-columns.d.ts +17 -0
  42. package/dist/inference/ui-contracts/rules/list-shows-business-columns.d.ts.map +1 -0
  43. package/dist/inference/ui-contracts/rules/list-shows-business-columns.js +39 -0
  44. package/dist/inference/ui-contracts/rules/list-shows-business-columns.js.map +1 -0
  45. package/dist/inference/ui-contracts/rules/list-view-renders.d.ts +19 -0
  46. package/dist/inference/ui-contracts/rules/list-view-renders.d.ts.map +1 -0
  47. package/dist/inference/ui-contracts/rules/list-view-renders.js +29 -0
  48. package/dist/inference/ui-contracts/rules/list-view-renders.js.map +1 -0
  49. package/dist/inference/ui-contracts/rules/nav-has-model-entries.d.ts +20 -0
  50. package/dist/inference/ui-contracts/rules/nav-has-model-entries.d.ts.map +1 -0
  51. package/dist/inference/ui-contracts/rules/nav-has-model-entries.js +29 -0
  52. package/dist/inference/ui-contracts/rules/nav-has-model-entries.js.map +1 -0
  53. package/dist/inference/ui-contracts/test-case-types.d.ts +126 -0
  54. package/dist/inference/ui-contracts/test-case-types.d.ts.map +1 -0
  55. package/dist/inference/ui-contracts/test-case-types.js +14 -0
  56. package/dist/inference/ui-contracts/test-case-types.js.map +1 -0
  57. package/dist/inference/ui-contracts/translator.d.ts +17 -0
  58. package/dist/inference/ui-contracts/translator.d.ts.map +1 -0
  59. package/dist/inference/ui-contracts/translator.js +127 -0
  60. package/dist/inference/ui-contracts/translator.js.map +1 -0
  61. package/dist/libs/instance-factories/applications/templates/react/api-client-generator.js +5 -2
  62. package/dist/libs/instance-factories/scaffolding/templates/generic/package-json-generator.js +10 -0
  63. package/dist/libs/instance-factories/views/templates/react/components-generator.js +34 -23
  64. package/dist/libs/instance-factories/views/templates/react/hooks-generator.js +51 -81
  65. package/dist/realize/index.d.ts.map +1 -1
  66. package/dist/realize/index.js +204 -0
  67. package/dist/realize/index.js.map +1 -1
  68. package/libs/instance-factories/applications/templates/react/api-client-generator.ts +5 -2
  69. package/libs/instance-factories/scaffolding/templates/generic/package-json-generator.ts +13 -0
  70. package/libs/instance-factories/views/templates/react/components-generator.ts +34 -23
  71. package/libs/instance-factories/views/templates/react/hooks-generator.ts +72 -88
  72. package/package.json +1 -1
@@ -0,0 +1,126 @@
1
+ /**
2
+ * TestCase — declarative description of a UI contract test.
3
+ *
4
+ * Rules (pure functions) produce TestCase[]. The translator renders
5
+ * each TestCase into a Playwright `.spec.ts` file that imports helpers
6
+ * from `@specverse/runtime/test-harness`. Neither rules nor test cases
7
+ * know anything about React Query, cache invalidation, or any
8
+ * implementation detail — they speak only spec language.
9
+ *
10
+ * v1 steps cover navigation + assertion. Later phases add create /
11
+ * delete / evolve / expect-absent for state-sync rules.
12
+ */
13
+ export type TestStep = {
14
+ action: 'bootRuntime';
15
+ } | {
16
+ action: 'expectNavHasModel';
17
+ modelName: string;
18
+ } | {
19
+ action: 'navigateToModel';
20
+ modelName: string;
21
+ } | {
22
+ action: 'expectViewRenders';
23
+ } | {
24
+ action: 'expectListColumn';
25
+ modelName: string;
26
+ attributeName: string;
27
+ } | {
28
+ action: 'expectFormInput';
29
+ modelName: string;
30
+ attributeName: string;
31
+ required?: boolean;
32
+ } | {
33
+ action: 'expectLifecycleState';
34
+ modelName: string;
35
+ stateName: string;
36
+ } | {
37
+ action: 'expectRelationshipSection';
38
+ modelName: string;
39
+ relationshipName: string;
40
+ } | {
41
+ action: 'expectActionButton';
42
+ modelName: string;
43
+ actionLabel: string;
44
+ } | {
45
+ action: 'createEntity';
46
+ modelName: string;
47
+ data: Record<string, unknown>;
48
+ bindAs?: string;
49
+ } | {
50
+ action: 'deleteEntity';
51
+ modelName: string;
52
+ idExpr: string;
53
+ } | {
54
+ action: 'evolveEntity';
55
+ modelName: string;
56
+ idExpr: string;
57
+ targetState: string;
58
+ } | {
59
+ action: 'expectEntityInList';
60
+ modelName: string;
61
+ displayValue: string;
62
+ } | {
63
+ action: 'expectEntityAbsent';
64
+ modelName: string;
65
+ displayValue: string;
66
+ };
67
+ export interface TestCase {
68
+ /** The rule that produced this test case. Used in test names for attribution. */
69
+ ruleId: string;
70
+ /** The spec element the case applies to (e.g. 'Poll', 'PollController.delete'). */
71
+ specElement: string;
72
+ /** Full test name — shown by Playwright reporter. */
73
+ name: string;
74
+ /** Ordered sequence of actions/assertions. */
75
+ steps: TestStep[];
76
+ }
77
+ /**
78
+ * A rule is a pure function from a normalized spec to a list of test
79
+ * cases. Rules never touch the filesystem, the network, or any mutable
80
+ * state — they just describe what should be tested.
81
+ */
82
+ export type UiContractRule = (spec: NormalizedSpec) => TestCase[];
83
+ /**
84
+ * Minimal normalized spec shape the rules operate on. The realize
85
+ * pipeline converts raw spec output into this shape before running
86
+ * rules. Kept deliberately small — rules grow their own helpers as
87
+ * they need richer data.
88
+ */
89
+ export interface NormalizedSpec {
90
+ models: ModelInfo[];
91
+ controllers?: ControllerInfo[];
92
+ services?: ServiceInfo[];
93
+ views?: ViewInfo[];
94
+ }
95
+ export interface ModelInfo {
96
+ name: string;
97
+ attributes?: AttributeInfo[];
98
+ lifecycles?: Record<string, unknown>;
99
+ relationships?: RelationshipInfo[];
100
+ }
101
+ export interface AttributeInfo {
102
+ name: string;
103
+ type?: string;
104
+ required?: boolean;
105
+ category?: 'business' | 'metadata' | 'relationship';
106
+ }
107
+ export interface RelationshipInfo {
108
+ name: string;
109
+ type: string;
110
+ target?: string;
111
+ }
112
+ export interface ControllerInfo {
113
+ name: string;
114
+ model?: string;
115
+ cured?: Record<string, unknown>;
116
+ }
117
+ export interface ServiceInfo {
118
+ name: string;
119
+ operations?: Record<string, unknown>;
120
+ }
121
+ export interface ViewInfo {
122
+ name: string;
123
+ type: string;
124
+ model?: string;
125
+ }
126
+ //# sourceMappingURL=test-case-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-case-types.d.ts","sourceRoot":"","sources":["../../../src/inference/ui-contracts/test-case-types.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * TestCase — declarative description of a UI contract test.
3
+ *
4
+ * Rules (pure functions) produce TestCase[]. The translator renders
5
+ * each TestCase into a Playwright `.spec.ts` file that imports helpers
6
+ * from `@specverse/runtime/test-harness`. Neither rules nor test cases
7
+ * know anything about React Query, cache invalidation, or any
8
+ * implementation detail — they speak only spec language.
9
+ *
10
+ * v1 steps cover navigation + assertion. Later phases add create /
11
+ * delete / evolve / expect-absent for state-sync rules.
12
+ */
13
+ export {};
14
+ //# sourceMappingURL=test-case-types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-case-types.js","sourceRoot":"","sources":["../../../src/inference/ui-contracts/test-case-types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * TestCase → Playwright .spec.ts translator.
3
+ *
4
+ * Renders a list of TestCase objects into a single Playwright spec
5
+ * file. The output file imports from `@specverse/runtime/test-harness`
6
+ * and nothing else from the runtime — all logic lives in the harness
7
+ * helpers. The translator is intentionally dumb: each step maps to a
8
+ * helper call, nothing more.
9
+ */
10
+ import type { TestCase } from './test-case-types.js';
11
+ /**
12
+ * Render a full `.spec.ts` file for one or more test cases sharing
13
+ * the same rule. One file per rule keeps failures easy to attribute
14
+ * and lets `npx playwright test` filter by rule ID via file glob.
15
+ */
16
+ export declare function renderSpecFile(ruleId: string, testCases: TestCase[]): string;
17
+ //# sourceMappingURL=translator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"translator.d.ts","sourceRoot":"","sources":["../../../src/inference/ui-contracts/translator.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAY,MAAM,sBAAsB,CAAC;AAwF/D;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,MAAM,CA6B5E"}
@@ -0,0 +1,127 @@
1
+ /**
2
+ * TestCase → Playwright .spec.ts translator.
3
+ *
4
+ * Renders a list of TestCase objects into a single Playwright spec
5
+ * file. The output file imports from `@specverse/runtime/test-harness`
6
+ * and nothing else from the runtime — all logic lives in the harness
7
+ * helpers. The translator is intentionally dumb: each step maps to a
8
+ * helper call, nothing more.
9
+ */
10
+ /**
11
+ * Render a single step as a line of test body code.
12
+ */
13
+ function renderStep(step) {
14
+ switch (step.action) {
15
+ case 'bootRuntime':
16
+ // Assign `page` once per test body. Multiple bootRuntime steps
17
+ // inside one test aren't supported and would shadow the binding.
18
+ return ` const { page } = await bootRuntime(browser);`;
19
+ case 'expectNavHasModel':
20
+ return ` await expectNavHasModel(page, ${JSON.stringify(step.modelName)});`;
21
+ case 'navigateToModel':
22
+ return ` await navigateToModel(page, ${JSON.stringify(step.modelName)});`;
23
+ case 'expectViewRenders':
24
+ return ` await expectViewRenders(page);`;
25
+ case 'expectListColumn':
26
+ return ` await expectListColumn(page, ${JSON.stringify(step.modelName)}, ${JSON.stringify(step.attributeName)});`;
27
+ case 'expectFormInput': {
28
+ const opts = step.required ? `, { required: true }` : ``;
29
+ return ` await expectFormInput(page, ${JSON.stringify(step.modelName)}, ${JSON.stringify(step.attributeName)}${opts});`;
30
+ }
31
+ case 'expectLifecycleState':
32
+ return ` await expectLifecycleState(page, ${JSON.stringify(step.modelName)}, ${JSON.stringify(step.stateName)});`;
33
+ case 'expectRelationshipSection':
34
+ return ` await expectRelationshipSection(page, ${JSON.stringify(step.modelName)}, ${JSON.stringify(step.relationshipName)});`;
35
+ case 'expectActionButton':
36
+ return ` await expectActionButton(page, ${JSON.stringify(step.modelName)}, ${JSON.stringify(step.actionLabel)});`;
37
+ case 'createEntity': {
38
+ const varName = step.bindAs || '_entityId';
39
+ const dataLit = JSON.stringify(step.data);
40
+ return ` const ${varName} = await createEntity(page, ${JSON.stringify(step.modelName)}, ${dataLit});`;
41
+ }
42
+ case 'deleteEntity':
43
+ return ` await deleteEntity(page, ${JSON.stringify(step.modelName)}, ${step.idExpr});`;
44
+ case 'evolveEntity':
45
+ return ` await evolveEntity(page, ${JSON.stringify(step.modelName)}, ${step.idExpr}, ${JSON.stringify(step.targetState)});`;
46
+ case 'expectEntityInList':
47
+ return ` await expectEntityInList(page, ${JSON.stringify(step.modelName)}, ${JSON.stringify(step.displayValue)});`;
48
+ case 'expectEntityAbsent':
49
+ return ` await expectEntityAbsent(page, ${JSON.stringify(step.modelName)}, ${JSON.stringify(step.displayValue)});`;
50
+ default: {
51
+ const _exhaustive = step;
52
+ throw new Error(`translator: unknown step action: ${JSON.stringify(_exhaustive)}`);
53
+ }
54
+ }
55
+ }
56
+ /**
57
+ * Collect the set of harness helpers a test case depends on so the
58
+ * import list only contains what's actually used.
59
+ */
60
+ function helpersFor(step) {
61
+ switch (step.action) {
62
+ case 'bootRuntime':
63
+ return ['bootRuntime'];
64
+ case 'expectNavHasModel':
65
+ return ['expectNavHasModel'];
66
+ case 'navigateToModel':
67
+ return ['navigateToModel'];
68
+ case 'expectViewRenders':
69
+ return ['expectViewRenders'];
70
+ case 'expectListColumn':
71
+ return ['expectListColumn'];
72
+ case 'expectFormInput':
73
+ return ['expectFormInput'];
74
+ case 'expectLifecycleState':
75
+ return ['expectLifecycleState'];
76
+ case 'expectRelationshipSection':
77
+ return ['expectRelationshipSection'];
78
+ case 'expectActionButton':
79
+ return ['expectActionButton'];
80
+ case 'createEntity':
81
+ return ['createEntity'];
82
+ case 'deleteEntity':
83
+ return ['deleteEntity'];
84
+ case 'evolveEntity':
85
+ return ['evolveEntity'];
86
+ case 'expectEntityInList':
87
+ return ['expectEntityInList'];
88
+ case 'expectEntityAbsent':
89
+ return ['expectEntityAbsent'];
90
+ default:
91
+ return [];
92
+ }
93
+ }
94
+ /**
95
+ * Render a full `.spec.ts` file for one or more test cases sharing
96
+ * the same rule. One file per rule keeps failures easy to attribute
97
+ * and lets `npx playwright test` filter by rule ID via file glob.
98
+ */
99
+ export function renderSpecFile(ruleId, testCases) {
100
+ const helpers = new Set();
101
+ for (const tc of testCases) {
102
+ for (const step of tc.steps) {
103
+ for (const h of helpersFor(step))
104
+ helpers.add(h);
105
+ }
106
+ }
107
+ const importList = [...helpers].sort().join(', ');
108
+ const header = `/**
109
+ * Auto-generated UI contract test file.
110
+ * Rule: ${ruleId}
111
+ * DO NOT EDIT — regenerated every \`spv realize\` run. Hand edits are
112
+ * lost on the next regenerate. Change the rule or the spec instead.
113
+ */
114
+
115
+ import { test } from '@playwright/test';
116
+ import { ${importList} } from '@specverse/runtime/test-harness';
117
+ `;
118
+ const body = testCases.map(tc => {
119
+ const stepLines = tc.steps.map(renderStep).join('\n');
120
+ return `
121
+ test(${JSON.stringify(tc.name)}, async ({ browser }) => {
122
+ ${stepLines}
123
+ });`;
124
+ }).join('\n');
125
+ return header + body + '\n';
126
+ }
127
+ //# sourceMappingURL=translator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"translator.js","sourceRoot":"","sources":["../../../src/inference/ui-contracts/translator.ts"],"names":[],"mappings":"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"}
@@ -43,9 +43,12 @@ export const WS_URL = apiUrl
43
43
  : \`\${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}/\${window.location.host}/ws\`;
44
44
 
45
45
  /**
46
- * Generic API request helper
46
+ * Generic API request helper \u2014 exported so view components can
47
+ * use it as a drop-in replacement for raw fetch calls. Always
48
+ * routes through this module so the conformance check
49
+ * \`no-raw-fetch-in-views\` stays green.
47
50
  */
48
- async function apiRequest<T = any>(
51
+ export async function apiRequest<T = any>(
49
52
  method: string,
50
53
  path: string,
51
54
  body: any = null
@@ -29,6 +29,13 @@ function generatePackageJson(context) {
29
29
  "test": "npm test --workspaces",
30
30
  "test:backend": `npm test --workspace=${backendDir}`,
31
31
  "test:frontend": `npm test --workspace=${frontendDir}`,
32
+ // UI contract tests — auto-generated from spec inference rules.
33
+ // Assumes backend + frontend are already running (see docs/proposals/UI-CONTRACT-INFERENCE.md).
34
+ "test:contract": "playwright test --config=playwright.contract.config.ts",
35
+ // Implementation conformance — static grep-level checks that
36
+ // verify the runtime implementation has no escape hatches
37
+ // violating the design contract. Fast (millis), no browser.
38
+ "test:conformance": "node scripts/test-conformance.mjs",
32
39
  // Database management (backend)
33
40
  "db:setup": `npm run db:setup --workspace=${backendDir}`,
34
41
  "db:generate": `npm run db:generate --workspace=${backendDir}`,
@@ -39,6 +46,9 @@ function generatePackageJson(context) {
39
46
  "lint": "npm run lint --workspaces",
40
47
  "lint:fix": "npm run lint:fix --workspaces"
41
48
  };
49
+ pkg.devDependencies = {
50
+ "@playwright/test": "^1.40.0"
51
+ };
42
52
  } else {
43
53
  pkg.scripts = {
44
54
  ...aggregated.scripts,
@@ -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 ` fetch('/api/${tLower}s').then(r => r.json()).then(data => {
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
- fetch('${api}').then(r => r.json()).then(data => {
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}) fetch(\`/api/${tLower}s/\${data.${fk}}\`).then(r => r.json()).then(d => set${r.target}Ref(d)).catch(() => {});`;
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 ` fetch(\`/api/${tLower}s?${lower}Id=\${data.id}\`).then(r => r.json()).then(d => set${capitalize(r.name)}Items(Array.isArray(d) ? d : [])).catch(() => {});`;
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
- fetch(\`${api}/\${id}\`).then(r => r.ok ? r.json() : null).then(data => {
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 fetch(\`${api}/\${id}\`, { method: 'DELETE' });
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 ` fetch('/api/${tLower}s').then(r => r.json()).then(d => set${r.target}Options(Array.isArray(d) ? d : [])).catch(() => {});`;
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
- fetch(\`${api}/\${editId}\`).then(r => r.json()).then(data => setForm(data || {})).catch(() => {});
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
- const method = editId ? 'PUT' : 'POST';
422
- const url = editId ? \`${api}/\${editId}\` : '${api}';
423
- const res = await fetch(url, {
424
- method,
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
- fetch('${api}').then(r => r.json()).then(data => {
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
- fetch('${api}').then(r => r.json()).then(data => {
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
- fetch('${api}').then(r => r.json()).then(data => {
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
- fetch('${api}').then(r => r.json()).then(data => {
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
- fetch('${api}').then(r => r.json()).then(data => {
758
+ apiRequest('GET', '${apiPath}').then(data => {
748
759
  setItems(Array.isArray(data) ? data : []);
749
760
  setLoading(false);
750
761
  }).catch(() => setLoading(false));