@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.
- package/dist/inference/core/specly-converter.d.ts.map +1 -1
- package/dist/inference/core/specly-converter.js +43 -22
- package/dist/inference/core/specly-converter.js.map +1 -1
- package/dist/inference/index.d.ts.map +1 -1
- package/dist/inference/index.js +29 -0
- package/dist/inference/index.js.map +1 -1
- package/dist/libs/instance-factories/applications/templates/react/index-html-generator.js +2 -2
- package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +291 -59
- package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +37 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +9 -124
- package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +9 -75
- package/dist/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.js +129 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +9 -212
- package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +260 -5
- package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +8 -50
- package/dist/libs/instance-factories/applications/templates/react-starter/operation-emitters.js +470 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/operation-view-generator.js +136 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/package-json-generator.js +2 -1
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
- package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +49 -10
- package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +223 -10
- package/dist/libs/instance-factories/applications/templates/react-starter/views-generator.js +14 -1
- package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +13 -1
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +18 -0
- package/libs/instance-factories/applications/templates/react/index-html-generator.ts +2 -2
- package/libs/instance-factories/applications/templates/react-starter/__tests__/dashboard-body-composer.test.ts +3 -1
- package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +24 -25
- package/libs/instance-factories/applications/templates/react-starter/__tests__/list-body-composer.test.ts +3 -3
- package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +2 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/parity-p3-rendered-output.test.ts +1 -1
- package/libs/instance-factories/applications/templates/react-starter/__tests__/starter-generators.test.ts +5 -4
- package/libs/instance-factories/applications/templates/react-starter/__tests__/views-generator.test.ts +11 -4
- package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +377 -71
- package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +66 -0
- package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +15 -182
- package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +16 -128
- package/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.ts +233 -0
- package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +16 -376
- package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +282 -4
- package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +26 -135
- package/libs/instance-factories/applications/templates/react-starter/operation-emitters.ts +497 -0
- package/libs/instance-factories/applications/templates/react-starter/operation-view-generator.ts +209 -0
- package/libs/instance-factories/applications/templates/react-starter/package-json-generator.ts +1 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
- package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
- package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
- package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +71 -10
- package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +359 -18
- package/libs/instance-factories/applications/templates/react-starter/views-generator.ts +28 -1
- package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +13 -1
- package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +18 -0
- package/package.json +2 -2
package/libs/instance-factories/applications/templates/react-starter/operation-view-generator.ts
ADDED
|
@@ -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 => ({ '<': '<', '>': '>', '&': '&' }[ch]!));
|
|
209
|
+
}
|
package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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 ===
|
|
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
|
|
51
|
-
<
|
|
52
|
-
{
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
{
|
|
87
|
-
|
|
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
|
}
|
package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template
CHANGED
|
@@ -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'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
{
|
|
87
|
-
|
|
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={
|
|
101
|
-
className=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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. */}
|