@specverse/engines 4.2.2 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/inference/core/specly-converter.d.ts.map +1 -1
  2. package/dist/inference/core/specly-converter.js +43 -22
  3. package/dist/inference/core/specly-converter.js.map +1 -1
  4. package/dist/inference/index.d.ts.map +1 -1
  5. package/dist/inference/index.js +29 -0
  6. package/dist/inference/index.js.map +1 -1
  7. package/dist/libs/instance-factories/applications/templates/react/index-html-generator.js +2 -2
  8. package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +291 -59
  9. package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +37 -0
  10. package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +9 -124
  11. package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +9 -75
  12. package/dist/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.js +129 -0
  13. package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +9 -212
  14. package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +260 -5
  15. package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +8 -50
  16. package/dist/libs/instance-factories/applications/templates/react-starter/operation-emitters.js +470 -0
  17. package/dist/libs/instance-factories/applications/templates/react-starter/operation-view-generator.js +136 -0
  18. package/dist/libs/instance-factories/applications/templates/react-starter/package-json-generator.js +2 -1
  19. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  20. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  21. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  22. package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +49 -10
  23. package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +223 -10
  24. package/dist/libs/instance-factories/applications/templates/react-starter/views-generator.js +14 -1
  25. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +13 -1
  26. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +18 -0
  27. package/libs/instance-factories/applications/templates/react/index-html-generator.ts +2 -2
  28. package/libs/instance-factories/applications/templates/react-starter/__tests__/dashboard-body-composer.test.ts +3 -1
  29. package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +24 -25
  30. package/libs/instance-factories/applications/templates/react-starter/__tests__/list-body-composer.test.ts +3 -3
  31. package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +2 -0
  32. package/libs/instance-factories/applications/templates/react-starter/__tests__/parity-p3-rendered-output.test.ts +1 -1
  33. package/libs/instance-factories/applications/templates/react-starter/__tests__/starter-generators.test.ts +5 -4
  34. package/libs/instance-factories/applications/templates/react-starter/__tests__/views-generator.test.ts +11 -4
  35. package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +377 -71
  36. package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +66 -0
  37. package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +15 -182
  38. package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +16 -128
  39. package/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.ts +233 -0
  40. package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +16 -376
  41. package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +282 -4
  42. package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +26 -135
  43. package/libs/instance-factories/applications/templates/react-starter/operation-emitters.ts +497 -0
  44. package/libs/instance-factories/applications/templates/react-starter/operation-view-generator.ts +209 -0
  45. package/libs/instance-factories/applications/templates/react-starter/package-json-generator.ts +1 -0
  46. package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  47. package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  48. package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  49. package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +71 -10
  50. package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +359 -18
  51. package/libs/instance-factories/applications/templates/react-starter/views-generator.ts +28 -1
  52. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +13 -1
  53. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +18 -0
  54. package/package.json +2 -2
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Per-operation view generator for ReactAppStarter
3
+ *
4
+ * Auto-generates `src/views/{Op}View.tsx` files for every non-standard
5
+ * controller command and every service operation. Each generated view
6
+ * is a thin shell that imports `OperationExecutor` from `../lib/` and
7
+ * invokes it with the op's metadata.
8
+ *
9
+ * Logic ported from app-demo's runtime-engine.ts:380-427 — same skip
10
+ * list for "standard" CURVED-protocol operations so we don't double
11
+ * up on things that already have declared list/detail/form/dashboard
12
+ * views.
13
+ */
14
+
15
+ /**
16
+ * CURVED + lifecycle + framework ops that should NOT get an operation
17
+ * view. These either have dedicated CRUD views, are runtime helpers,
18
+ * or are relationship-integrity checks. Copied verbatim from
19
+ * app-demo/src/runtime/runtime-engine.ts:382-388.
20
+ */
21
+ const STANDARD_OPS = new Set([
22
+ 'create', 'retrieve', 'retrieve_many', 'update', 'delete',
23
+ 'validate', 'evolve', 'list', 'query', 'get',
24
+ 'attachProfile', 'detachProfile', 'hasProfile',
25
+ 'handleChildAdded', 'handleChildRemoved',
26
+ 'validateRelationshipIntegrity', 'repairRelationshipIntegrity',
27
+ ]);
28
+
29
+ export interface OperationViewDescriptor {
30
+ /** File name without extension, e.g. `PublishView`. */
31
+ viewName: string;
32
+ /** Human-readable label for the page. */
33
+ label: string;
34
+ /** Service or controller class name (e.g. `AuthService`, `PostController`). */
35
+ serviceName: string;
36
+ /** Operation method name (e.g. `login`, `publish`). */
37
+ operationName: string;
38
+ /** Parameters declaration. Accepted in either `{ name: def }` or array form. */
39
+ parameters?: Record<string, any> | Array<any>;
40
+ /** Optional: parameter names that must be supplied before Execute. */
41
+ requires?: string[];
42
+ /** Optional: display return-type hint next to the title. */
43
+ returns?: string;
44
+ /** Source: determines which endpoint shape the executor uses. */
45
+ source: 'service' | 'controller';
46
+ }
47
+
48
+ /**
49
+ * Normalize a parameters block into `{ name: typeStr }`. Handles
50
+ * every shape the parser / inference pipeline can emit:
51
+ * - `query: String` → `{ query: "String" }`
52
+ * - `query: { type: "String" }` → `{ query: "String" }`
53
+ * - `id: "UUID required"` → `{ id: "UUID" }` (strip modifiers)
54
+ * - Broken shapes (e.g. inference bug producing `["object Object"]`
55
+ * for services) fall back to "String" so the executor renders
56
+ * a plain text input instead of blowing up.
57
+ */
58
+ function normalizeParameters(raw: any): Record<string, string> | undefined {
59
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
60
+ const out: Record<string, string> = {};
61
+ for (const [name, def] of Object.entries(raw)) {
62
+ let type: string;
63
+ if (typeof def === 'string') {
64
+ type = def;
65
+ } else if (def && typeof def === 'object' && 'type' in def && typeof (def as any).type === 'string') {
66
+ type = (def as any).type;
67
+ } else {
68
+ // Malformed entry (e.g. inference bug on services) — render as text input.
69
+ type = 'String';
70
+ }
71
+ // Strip space-delim modifiers: "UUID required" → "UUID".
72
+ out[name] = (type.split(/\s+/)[0] || 'String');
73
+ }
74
+ return Object.keys(out).length > 0 ? out : undefined;
75
+ }
76
+
77
+ /**
78
+ * Enumerate all operation views to generate for a spec. Returns an
79
+ * array of descriptors — one per non-standard command + service op.
80
+ * Consumers emit a `.tsx` per descriptor via `emitOperationView`.
81
+ */
82
+ export function collectOperationViews(spec: {
83
+ controllers?: Record<string, any>;
84
+ services?: Record<string, any> | any[];
85
+ }): OperationViewDescriptor[] {
86
+ const out: OperationViewDescriptor[] = [];
87
+ const seen = new Set<string>();
88
+
89
+ // Non-standard controller commands. A controller's operations object
90
+ // may use `commands` or `operations` as its key — accept both so we
91
+ // don't mis-classify a spec that uses either form.
92
+ const controllers = spec.controllers ?? {};
93
+ for (const [ctlName, ctl] of Object.entries(controllers)) {
94
+ const ops = (ctl?.operations ?? ctl?.commands ?? {}) as Record<string, any>;
95
+ for (const [opName, op] of Object.entries(ops)) {
96
+ if (STANDARD_OPS.has(opName)) continue;
97
+ const viewName = capitalize(opName) + 'View';
98
+ if (seen.has(viewName)) continue;
99
+ seen.add(viewName);
100
+ out.push({
101
+ viewName,
102
+ label: humanize(opName),
103
+ serviceName: ctlName,
104
+ operationName: opName,
105
+ parameters: normalizeParameters(op?.parameters ?? op?.params),
106
+ requires: op?.requires,
107
+ returns: op?.returns,
108
+ source: 'controller',
109
+ });
110
+ }
111
+ }
112
+
113
+ // Services. Operations may be an object keyed by name OR an array
114
+ // of { name, parameters, ... } objects.
115
+ const services = spec.services ?? {};
116
+ for (const [svcName, svc] of Object.entries(services)) {
117
+ const rawOps = svc?.operations;
118
+ const opEntries: Array<[string, any]> = Array.isArray(rawOps)
119
+ ? rawOps.map((o: any) => [o.name, o])
120
+ : Object.entries((rawOps ?? {}) as Record<string, any>);
121
+ for (const [opName, op] of opEntries) {
122
+ if (!opName) continue;
123
+ if (STANDARD_OPS.has(opName)) continue;
124
+ const viewName = capitalize(opName) + 'View';
125
+ if (seen.has(viewName)) continue;
126
+ seen.add(viewName);
127
+ out.push({
128
+ viewName,
129
+ label: humanize(opName),
130
+ serviceName: svcName,
131
+ operationName: opName,
132
+ parameters: normalizeParameters(op?.parameters ?? op?.params),
133
+ requires: op?.requires,
134
+ returns: op?.returns,
135
+ source: 'service',
136
+ });
137
+ }
138
+ }
139
+
140
+ return out;
141
+ }
142
+
143
+ /**
144
+ * Emit a single `src/views/{Op}View.tsx` file. A thin shell that
145
+ * imports `OperationExecutor` from `../lib/` and passes the op's
146
+ * metadata as props. Matches the structure of app-demo's
147
+ * OperationView but inlined so users have one file per op to edit.
148
+ */
149
+ export function emitOperationView(desc: OperationViewDescriptor): string {
150
+ const paramsJson = desc.parameters
151
+ ? JSON.stringify(desc.parameters, null, 2).replace(/\n/g, '\n ')
152
+ : 'undefined';
153
+ const requiresJson = desc.requires
154
+ ? JSON.stringify(desc.requires)
155
+ : 'undefined';
156
+
157
+ const returnsPill = desc.returns
158
+ ? `\n <span className="text-xs px-2 py-0.5 bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded font-mono">\n → ${escapeJsx(desc.returns)}\n </span>`
159
+ : '';
160
+
161
+ return `/**
162
+ * ${desc.viewName} — generated by @specverse/realize (ReactAppStarter)
163
+ *
164
+ * Auto-generated operation view for ${desc.source} ${desc.serviceName}.${desc.operationName}.
165
+ * Safe to edit; the generator will not overwrite a hand-edited file
166
+ * (content-hashing in .specverse-gen/hashes.json).
167
+ */
168
+ import { OperationExecutor } from '../lib/OperationExecutor';
169
+
170
+ export function ${desc.viewName}() {
171
+ return (
172
+ <div className="p-6 space-y-4">
173
+ <div className="flex items-center gap-2">
174
+ <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
175
+ ${escapeJsx(desc.label)}
176
+ </h2>${returnsPill}
177
+ </div>
178
+ <p className="text-sm text-gray-500 dark:text-gray-400">
179
+ Execute ${escapeJsx(desc.serviceName)}.${escapeJsx(desc.operationName)}
180
+ </p>
181
+ <div className="max-w-lg">
182
+ <OperationExecutor
183
+ serviceName="${desc.serviceName}"
184
+ operationName="${desc.operationName}"
185
+ parameters={${paramsJson}}
186
+ requires={${requiresJson}}
187
+ source="${desc.source}"
188
+ />
189
+ </div>
190
+ </div>
191
+ );
192
+ }
193
+ `;
194
+ }
195
+
196
+ function capitalize(s: string): string {
197
+ return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
198
+ }
199
+
200
+ function humanize(s: string): string {
201
+ return s
202
+ .replace(/([A-Z])/g, ' $1')
203
+ .replace(/^./, c => c.toUpperCase())
204
+ .trim();
205
+ }
206
+
207
+ function escapeJsx(s: string): string {
208
+ return s.replace(/[<>&]/g, ch => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[ch]!));
209
+ }
@@ -33,6 +33,7 @@ export async function generate(context: PackageJsonGeneratorContext): Promise<st
33
33
  react: '^18.2.0',
34
34
  'react-dom': '^18.2.0',
35
35
  '@tanstack/react-query': '^5.0.0',
36
+ 'lucide-react': '^0.400.0',
36
37
  },
37
38
  devDependencies: {
38
39
  '@types/react': '^18.2.0',
@@ -4,93 +4,86 @@
4
4
  * Safe to edit. Edits are preserved across regeneration via content
5
5
  * hashing (see .specverse-gen/hashes.json). To accept an upstream
6
6
  * regeneration of this file, delete it first, then run `spv realize`.
7
+ *
8
+ * Inspection-only view: entity picker + rendered fields + related lists.
9
+ * All mutations (create, update, delete, evolve) live in the form view.
10
+ * To edit/delete, navigate to the form via the sidebar.
7
11
  */
8
- import { use{{PLURAL_MODEL}}Query, useDelete{{MODEL_NAME}}Mutation } from '../hooks/useApi';
12
+ {{RELATED_TAB_IMPORT}}
13
+ import { use{{PLURAL_MODEL}}Query } from '../hooks/useApi';
9
14
  {{RELATED_IMPORTS}}
10
15
  import type { {{MODEL_NAME}} } from '../types/api';
11
16
 
12
17
  interface {{MODEL_NAME}}DetailViewProps {
13
- entityId: string | number;
14
- onEdit?: (item: {{MODEL_NAME}}) => void;
15
- onBack?: () => void;
16
- /** Called after the delete mutation succeeds. */
17
- onDeleted?: () => void;
18
+ /** Initial selection. When absent, the first entity in the list is shown. */
19
+ entityId?: string | number;
20
+ /** Called when the user picks a different entity from the picker. */
21
+ onEntityChange?: (id: string | number) => void;
22
+ /** Called when the user clicks a resolved belongsTo cell or a related-list
23
+ * row. Wired by App.tsx to navigate to the target model's detail view. */
24
+ onNavigate?: (model: string, id: string | number) => void;
18
25
  }
19
26
 
20
27
  export function {{MODEL_NAME}}DetailView({
21
28
  entityId,
22
- onEdit,
23
- onBack,
24
- onDeleted,
29
+ onEntityChange,
30
+ onNavigate,
25
31
  }: {{MODEL_NAME}}DetailViewProps) {
32
+ // onNavigate is referenced from walker-emitted FK cells + related-row
33
+ // clicks below; reference it here so TS doesn't flag it unused for
34
+ // models without belongsTo or hasMany.
35
+ void onNavigate;
26
36
  const { data: items = [], isLoading, error } = use{{PLURAL_MODEL}}Query();
27
- const deleteItem = useDelete{{MODEL_NAME}}Mutation();
28
37
  {{RELATED_HOOKS}}
38
+ {{RELATED_TAB_STATE}}
29
39
 
40
+ // Resolve which entity to display: explicit prop wins, else first item.
41
+ const activeId = entityId ?? (items[0] as any)?.id;
30
42
  const item = items.find(
31
- (x: {{MODEL_NAME}}) => (x as any).id === entityId
43
+ (x: {{MODEL_NAME}}) => (x as any).id === activeId
32
44
  );
33
45
 
34
46
  if (isLoading) return <div className="p-4 text-gray-500">Loading…</div>;
35
47
  if (error) return <div className="p-4 text-red-600">Error loading {{PLURAL_LOWER}}: {String(error)}</div>;
36
- if (!item) return <div className="p-4 text-gray-400">No {{SINGULAR_LOWER}} matching id {String(entityId)}.</div>;
37
-
38
- const handleDelete = async () => {
39
- if (!confirm('Delete this {{SINGULAR_LOWER}}?')) return;
40
- try {
41
- await deleteItem.mutateAsync((item as any).id);
42
- onDeleted?.();
43
- } catch {
44
- // deleteItem.error is surfaced below
45
- }
46
- };
47
48
 
48
49
  return (
49
50
  <div className="p-6 space-y-4">
50
- <div className="flex items-center justify-between">
51
- <div className="flex items-center gap-3">
52
- {onBack && (
53
- <button
54
- type="button"
55
- onClick={onBack}
56
- className="text-sm text-gray-500 hover:text-gray-700"
57
- >
58
- ← Back
59
- </button>
60
- )}
61
- <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
62
- {{MODEL_NAME}} detail
63
- </h2>
64
- </div>
65
- <div className="flex gap-2">
66
- {onEdit && (
67
- <button
68
- type="button"
69
- onClick={() => onEdit(item)}
70
- className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
71
- >
72
- Edit
73
- </button>
51
+ <div className="flex items-center gap-3">
52
+ <label
53
+ htmlFor="{{SINGULAR_LOWER}}-picker"
54
+ className="text-sm font-medium text-gray-700 dark:text-gray-300"
55
+ >
56
+ Select {{MODEL_NAME}}:
57
+ </label>
58
+ <select
59
+ id="{{SINGULAR_LOWER}}-picker"
60
+ value={activeId == null ? '' : String(activeId)}
61
+ onChange={e => onEntityChange?.(e.target.value)}
62
+ className="flex-1 max-w-xl rounded border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
63
+ >
64
+ {items.length === 0 && (
65
+ <option value="">No {{PLURAL_LOWER}} yet</option>
74
66
  )}
75
- <button
76
- type="button"
77
- onClick={handleDelete}
78
- disabled={deleteItem.isPending}
79
- className="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60"
80
- >
81
- {deleteItem.isPending ? 'Deleting…' : 'Delete'}
82
- </button>
83
- </div>
67
+ {items.map((x: {{MODEL_NAME}}) => (
68
+ <option key={String((x as any).id)} value={String((x as any).id)}>
69
+ {getEntityDisplayName(x)}
70
+ </option>
71
+ ))}
72
+ </select>
84
73
  </div>
85
74
 
86
- {/* Pattern-rendered detail body (fields). Edit freely. */}
87
- {{BODY}}
88
-
89
- {deleteItem.isError && (
90
- <div className="p-2 text-sm text-red-600">
91
- Delete failed: {String(deleteItem.error)}
75
+ {!item && (
76
+ <div className="rounded-lg border border-gray-200 bg-white p-6 text-sm text-gray-400 dark:border-gray-700 dark:bg-gray-900">
77
+ No {{SINGULAR_LOWER}} to display.
92
78
  </div>
93
79
  )}
80
+
81
+ {item && (
82
+ <>
83
+ {/* Pattern-rendered detail body (fields + related). Edit freely. */}
84
+ {{BODY}}
85
+ </>
86
+ )}
94
87
  </div>
95
88
  );
96
89
  }
@@ -6,36 +6,56 @@
6
6
  * regeneration of this file, delete it first, then run `spv realize`.
7
7
  *
8
8
  * Controlled form. One field per non-auto-generated attribute.
9
- * Mode: 'create' (default) or 'update' passed as a prop.
9
+ * Mode: 'create' (default) or 'update'. When the model has one or
10
+ * more lifecycles AND mode = 'update', an "Evolve" tab appears
11
+ * alongside "Edit" that drives a state transition via PATCH /:id/evolve.
10
12
  */
11
13
  import { useEffect, useState } from 'react';
12
14
  import {
13
15
  use{{PLURAL_MODEL}}Query,
14
16
  useCreate{{MODEL_NAME}}Mutation,
15
17
  useUpdate{{MODEL_NAME}}Mutation,
18
+ useDelete{{MODEL_NAME}}Mutation,
19
+ {{LIFECYCLE_IMPORTS}}
16
20
  } from '../hooks/useApi';
17
21
  {{RELATED_IMPORTS}}
18
22
  import type { {{MODEL_NAME}} } from '../types/api';
23
+ import { {{MODEL_NAME}}ListView } from './{{MODEL_NAME}}ListView';
19
24
 
20
25
  type FormMode = 'create' | 'update';
26
+ type ActiveTab = 'edit' | 'evolve';
21
27
 
22
28
  interface {{MODEL_NAME}}FormViewProps {
23
29
  mode?: FormMode;
24
30
  /** Required in update mode. */
25
31
  entityId?: string | number;
26
32
  onSuccess?: (item: {{MODEL_NAME}}) => void;
27
- onCancel?: () => void;
33
+ /** Called after the delete mutation succeeds. Wired by App.tsx to
34
+ * navigate back to the list view. */
35
+ onDeleted?: () => void;
36
+ /** Called when the user clicks a row in the embedded
37
+ * {{MODEL_NAME}}ListView below the form. Wired by App.tsx to
38
+ * re-navigate to this form with `entityId` set, so clicking a
39
+ * row loads that entity's data into the form for update. */
40
+ onSelectEntity?: (item: {{MODEL_NAME}}) => void;
28
41
  }
29
42
 
30
43
  export function {{MODEL_NAME}}FormView({
31
44
  mode = 'create',
32
45
  entityId,
33
46
  onSuccess,
34
- onCancel,
47
+ onDeleted,
48
+ onSelectEntity,
35
49
  }: {{MODEL_NAME}}FormViewProps) {
50
+ // Referenced from the walker-emitted <{{MODEL_NAME}}ListView
51
+ // onSelect={onSelectEntity} /> below; void it here so strict TS
52
+ // doesn't flag it when body positioning elides the reference.
53
+ void onSelectEntity;
36
54
  const { data: items = [] } = use{{PLURAL_MODEL}}Query();
37
55
  const createItem = useCreate{{MODEL_NAME}}Mutation();
38
56
  const updateItem = useUpdate{{MODEL_NAME}}Mutation();
57
+ const deleteItem = useDelete{{MODEL_NAME}}Mutation();
58
+ {{LIFECYCLE_HOOK}}
39
59
  {{RELATED_HOOKS}}
40
60
 
41
61
  const existing =
@@ -44,6 +64,8 @@ export function {{MODEL_NAME}}FormView({
44
64
  : undefined;
45
65
 
46
66
  const [formData, setFormData] = useState<Partial<{{MODEL_NAME}}>>(existing ?? {});
67
+ const [activeTab, setActiveTab] = useState<ActiveTab>('edit');
68
+ {{LIFECYCLE_STATE}}
47
69
 
48
70
  // When the fetched list lands after initial render (update mode),
49
71
  // hydrate the form with the loaded entity's data.
@@ -55,6 +77,21 @@ export function {{MODEL_NAME}}FormView({
55
77
  setFormData(prev => ({ ...prev, [field]: value }) as Partial<{{MODEL_NAME}}>);
56
78
  };
57
79
 
80
+ const handleClear = () => {
81
+ setFormData(existing ?? {});
82
+ };
83
+
84
+ const handleDelete = async () => {
85
+ if (mode !== 'update' || entityId == null) return;
86
+ if (!confirm('Delete this {{SINGULAR_LOWER}}?')) return;
87
+ try {
88
+ await deleteItem.mutateAsync(entityId as any);
89
+ onDeleted?.();
90
+ } catch {
91
+ // deleteItem.error is surfaced below
92
+ }
93
+ };
94
+
58
95
  const handleSubmit = async (event: React.FormEvent) => {
59
96
  event.preventDefault();
60
97
  try {
@@ -71,46 +108,97 @@ export function {{MODEL_NAME}}FormView({
71
108
  }
72
109
  };
73
110
 
111
+ {{LIFECYCLE_HANDLER}}
112
+
74
113
  const mutation = mode === 'create' ? createItem : updateItem;
75
114
  const submitLabel =
76
115
  mode === 'create'
77
116
  ? mutation.isPending ? 'Creating…' : 'Create {{MODEL_NAME}}'
78
117
  : mutation.isPending ? 'Updating…' : 'Update {{MODEL_NAME}}';
79
118
 
119
+ const hasLifecycles = {{HAS_LIFECYCLES}};
120
+ const showTabs = mode === 'update' && hasLifecycles;
121
+
80
122
  return (
81
- <form onSubmit={handleSubmit} className="p-6 space-y-6">
123
+ <div className="p-6 space-y-6">
82
124
  <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
83
125
  {mode === 'create' ? 'New {{MODEL_NAME}}' : 'Edit {{MODEL_NAME}}'}
84
126
  </h2>
85
127
 
86
- {/* Pattern-rendered form fields. Edit freely. */}
87
- {{BODY}}
88
-
89
- <div className="flex gap-2 border-t border-gray-200 dark:border-gray-700 pt-4">
90
- <button
91
- type="submit"
92
- disabled={mutation.isPending}
93
- className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
94
- >
95
- {submitLabel}
96
- </button>
97
- {onCancel && (
128
+ {showTabs && (
129
+ <div className="flex gap-1 border-b border-gray-200 dark:border-gray-700">
98
130
  <button
99
131
  type="button"
100
- onClick={onCancel}
101
- className="rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
132
+ onClick={() => setActiveTab('edit')}
133
+ className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${activeTab === 'edit' ? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}`}
102
134
  >
103
- Cancel
135
+ Edit
136
+ </button>
137
+ <button
138
+ type="button"
139
+ onClick={() => setActiveTab('evolve')}
140
+ className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${activeTab === 'evolve' ? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}`}
141
+ >
142
+ Evolve
104
143
  </button>
105
- )}
106
- </div>
107
-
108
- {mutation.isError && (
109
- <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
110
- {mode === 'create' ? 'Create failed: ' : 'Update failed: '}
111
- {String(mutation.error)}
112
144
  </div>
113
145
  )}
114
- </form>
146
+
147
+ {(!showTabs || activeTab === 'edit') && (
148
+ <>
149
+ <form onSubmit={handleSubmit} className="space-y-6">
150
+ {/* Pattern-rendered form fields. Edit freely. */}
151
+ {{FIELDS_BODY}}
152
+
153
+ <div className="flex gap-2">
154
+ <button
155
+ type="submit"
156
+ disabled={mutation.isPending}
157
+ className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
158
+ >
159
+ {submitLabel}
160
+ </button>
161
+ {mode === 'update' && (
162
+ <button
163
+ type="button"
164
+ onClick={handleDelete}
165
+ disabled={deleteItem.isPending}
166
+ className="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60"
167
+ >
168
+ {deleteItem.isPending ? 'Deleting…' : 'Delete'}
169
+ </button>
170
+ )}
171
+ <button
172
+ type="button"
173
+ onClick={handleClear}
174
+ disabled={mutation.isPending}
175
+ className="rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-60 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
176
+ >
177
+ Clear
178
+ </button>
179
+ </div>
180
+
181
+ {mutation.isError && (
182
+ <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
183
+ {mode === 'create' ? 'Create failed: ' : 'Update failed: '}
184
+ {String(mutation.error)}
185
+ </div>
186
+ )}
187
+
188
+ {deleteItem.isError && (
189
+ <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
190
+ Delete failed: {String(deleteItem.error)}
191
+ </div>
192
+ )}
193
+ </form>
194
+
195
+ {/* Existing entities list sits outside the <form> so its
196
+ search input and inner interactions don't submit the form. */}
197
+ {{EXISTING_LIST_BODY}}
198
+ </>
199
+ )}
200
+
201
+ {{LIFECYCLE_PANEL}}
202
+ </div>
115
203
  );
116
204
  }
@@ -13,9 +13,15 @@ import type { {{MODEL_NAME}} } from '../types/api';
13
13
  interface {{MODEL_NAME}}ListViewProps {
14
14
  onSelect?: (item: {{MODEL_NAME}}) => void;
15
15
  onCreate?: () => void;
16
+ /** Called when the user clicks a resolved belongsTo cell.
17
+ * Wired by App.tsx to switch to the target model's detail view. */
18
+ onNavigate?: (model: string, id: string | number) => void;
16
19
  }
17
20
 
18
- export function {{MODEL_NAME}}ListView({ onSelect, onCreate }: {{MODEL_NAME}}ListViewProps) {
21
+ export function {{MODEL_NAME}}ListView({ onSelect, onCreate, onNavigate }: {{MODEL_NAME}}ListViewProps) {
22
+ // onNavigate is referenced from walker-emitted FK cells below; reference
23
+ // it here so TS doesn't flag it as unused for models without belongsTo.
24
+ void onNavigate;
19
25
  const { data: items = [], isLoading, error } = use{{PLURAL_MODEL}}Query();
20
26
  const deleteItem = useDelete{{MODEL_NAME}}Mutation();
21
27
  {{RELATED_HOOKS}}
@@ -46,15 +52,17 @@ export function {{MODEL_NAME}}ListView({ onSelect, onCreate }: {{MODEL_NAME}}Lis
46
52
  placeholder="Search {{PLURAL_LOWER}}…"
47
53
  value={searchTerm}
48
54
  onChange={e => setSearchTerm(e.target.value)}
49
- className="w-64 rounded border border-gray-300 px-3 py-2 text-sm"
55
+ className="w-64 rounded border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
50
56
  />
51
- <button
52
- type="button"
53
- onClick={onCreate}
54
- className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
55
- >
56
- + New {{MODEL_NAME}}
57
- </button>
57
+ {onCreate && (
58
+ <button
59
+ type="button"
60
+ onClick={onCreate}
61
+ className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
62
+ >
63
+ + New {{MODEL_NAME}}
64
+ </button>
65
+ )}
58
66
  </div>
59
67
 
60
68
  {/* Pattern-rendered list body (table + rows). Edit freely. */}