@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
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operation-view emitters for ReactAppStarter
|
|
3
|
+
*
|
|
4
|
+
* Source strings for the three `src/lib/*.tsx` files that back the
|
|
5
|
+
* auto-generated operation views:
|
|
6
|
+
*
|
|
7
|
+
* - FieldInput.tsx (+ EntitySelect) — type-aware parameter inputs.
|
|
8
|
+
* - OperationExecutor.tsx — param form + Execute button + result.
|
|
9
|
+
* - OperationResultView.tsx — formatted result / error pane.
|
|
10
|
+
*
|
|
11
|
+
* Ported from app-demo (frontend-react/src/lib/view-components/
|
|
12
|
+
* FieldInput.tsx, components/runtime/OperationExecutor.tsx,
|
|
13
|
+
* components/runtime/OperationResultView.tsx). Adaptations:
|
|
14
|
+
*
|
|
15
|
+
* - EntitySelect fetch: POST /controllers/{M}Controller/list →
|
|
16
|
+
* GET /api/{resource} (static REST convention).
|
|
17
|
+
* - Response-shape: app-demo wraps entities in data.data?.entities
|
|
18
|
+
* or data.entities; static returns a flat array.
|
|
19
|
+
* - Entity id extraction: entity.data?.id || entity.id →
|
|
20
|
+
* entity.id (no interim IDs in static).
|
|
21
|
+
* - OperationExecutor controller URL:
|
|
22
|
+
* apiExecuteOperation(controllerName, op, params) →
|
|
23
|
+
* POST /api/{resource}/{op} where resource = plural lowercase
|
|
24
|
+
* of (controllerName minus "Controller" suffix).
|
|
25
|
+
*
|
|
26
|
+
* The three files are emitted by views-generator.ts into src/lib/
|
|
27
|
+
* alongside entity-display.ts. They have no @specverse/* runtime
|
|
28
|
+
* dependency; lucide-react is added to the generated package.json.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export function emitFieldInput(): string {
|
|
32
|
+
return `/**
|
|
33
|
+
* FieldInput — type-aware input component used by OperationExecutor.
|
|
34
|
+
*
|
|
35
|
+
* Inlined into this project by @specverse/realize (ReactAppStarter).
|
|
36
|
+
* Edit freely. Ported from app-demo's FieldInput.
|
|
37
|
+
*
|
|
38
|
+
* Renders the right input for a parameter type:
|
|
39
|
+
* - UUID ending in Id → entity dropdown via EntitySelect
|
|
40
|
+
* - Boolean → checkbox (or select in compact mode)
|
|
41
|
+
* - Integer/Number/Decimal → number input
|
|
42
|
+
* - DateTime/Date → native picker
|
|
43
|
+
* - Email → email input
|
|
44
|
+
* - Text (multiline) → textarea
|
|
45
|
+
* - default → text input
|
|
46
|
+
*/
|
|
47
|
+
import { useState, useEffect } from 'react';
|
|
48
|
+
import { getEntityDisplayName } from './entity-display';
|
|
49
|
+
|
|
50
|
+
const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
|
|
51
|
+
|
|
52
|
+
function pluralize(s: string): string {
|
|
53
|
+
if (/[^aeiou]y$/i.test(s)) return s.slice(0, -1) + 'ies';
|
|
54
|
+
if (/(s|x|z|ch|sh)$/i.test(s)) return s + 'es';
|
|
55
|
+
return s + 's';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function humanizeFieldName(name: string): string {
|
|
59
|
+
return name
|
|
60
|
+
.replace(/([A-Z])/g, ' $1')
|
|
61
|
+
.replace(/^./, c => c.toUpperCase())
|
|
62
|
+
.trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface FieldInputProps {
|
|
66
|
+
name: string;
|
|
67
|
+
typeStr: string;
|
|
68
|
+
value: any;
|
|
69
|
+
onChange: (value: any) => void;
|
|
70
|
+
required?: boolean;
|
|
71
|
+
disabled?: boolean;
|
|
72
|
+
/** Compact mode for inline use (smaller padding, no label). */
|
|
73
|
+
compact?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const inputClass = (compact: boolean) => compact
|
|
77
|
+
? 'w-full px-2 py-1 text-xs bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-1 focus:ring-blue-500'
|
|
78
|
+
: 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-800 disabled:cursor-not-allowed';
|
|
79
|
+
|
|
80
|
+
export function FieldInput({ name, typeStr, value, onChange, required, disabled, compact = false }: FieldInputProps) {
|
|
81
|
+
const type = typeStr.toLowerCase();
|
|
82
|
+
|
|
83
|
+
if (type.includes('bool')) {
|
|
84
|
+
return compact ? (
|
|
85
|
+
<select
|
|
86
|
+
value={value ?? ''}
|
|
87
|
+
onChange={e => onChange(e.target.value === 'true')}
|
|
88
|
+
disabled={disabled}
|
|
89
|
+
className={inputClass(compact)}
|
|
90
|
+
>
|
|
91
|
+
<option value="">Select...</option>
|
|
92
|
+
<option value="true">true</option>
|
|
93
|
+
<option value="false">false</option>
|
|
94
|
+
</select>
|
|
95
|
+
) : (
|
|
96
|
+
<div className="flex items-center pt-2">
|
|
97
|
+
<input
|
|
98
|
+
type="checkbox"
|
|
99
|
+
checked={!!value}
|
|
100
|
+
onChange={e => onChange(e.target.checked)}
|
|
101
|
+
disabled={disabled}
|
|
102
|
+
className="h-4 w-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
|
|
103
|
+
/>
|
|
104
|
+
<span className="ml-2 text-sm text-gray-600 dark:text-gray-300">
|
|
105
|
+
{value ? 'Yes' : 'No'}
|
|
106
|
+
</span>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (type.includes('int') || type.includes('number') || type.includes('decimal')) {
|
|
112
|
+
return (
|
|
113
|
+
<input
|
|
114
|
+
type="number"
|
|
115
|
+
step={type.includes('decimal') ? '0.01' : '1'}
|
|
116
|
+
value={value ?? ''}
|
|
117
|
+
onChange={e => onChange(type.includes('int') ? parseInt(e.target.value) || 0 : parseFloat(e.target.value) || 0)}
|
|
118
|
+
disabled={disabled}
|
|
119
|
+
required={required}
|
|
120
|
+
className={inputClass(compact)}
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (type.includes('datetime')) {
|
|
126
|
+
return (
|
|
127
|
+
<input
|
|
128
|
+
type="datetime-local"
|
|
129
|
+
value={value ? new Date(value).toISOString().slice(0, 16) : ''}
|
|
130
|
+
onChange={e => onChange(e.target.value ? new Date(e.target.value).toISOString() : '')}
|
|
131
|
+
disabled={disabled}
|
|
132
|
+
required={required}
|
|
133
|
+
className={inputClass(compact)}
|
|
134
|
+
/>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (type.includes('date')) {
|
|
139
|
+
return (
|
|
140
|
+
<input
|
|
141
|
+
type="date"
|
|
142
|
+
value={value ? new Date(value).toISOString().slice(0, 10) : ''}
|
|
143
|
+
onChange={e => onChange(e.target.value ? new Date(e.target.value).toISOString() : '')}
|
|
144
|
+
disabled={disabled}
|
|
145
|
+
required={required}
|
|
146
|
+
className={inputClass(compact)}
|
|
147
|
+
/>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (type.includes('email')) {
|
|
152
|
+
return (
|
|
153
|
+
<input
|
|
154
|
+
type="email"
|
|
155
|
+
value={value ?? ''}
|
|
156
|
+
onChange={e => onChange(e.target.value)}
|
|
157
|
+
disabled={disabled}
|
|
158
|
+
required={required}
|
|
159
|
+
placeholder={humanizeFieldName(name)}
|
|
160
|
+
className={inputClass(compact)}
|
|
161
|
+
/>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (type === 'text') {
|
|
166
|
+
return (
|
|
167
|
+
<textarea
|
|
168
|
+
value={value ?? ''}
|
|
169
|
+
onChange={e => onChange(e.target.value)}
|
|
170
|
+
disabled={disabled}
|
|
171
|
+
required={required}
|
|
172
|
+
rows={compact ? 2 : 3}
|
|
173
|
+
placeholder={humanizeFieldName(name)}
|
|
174
|
+
className={\`\${inputClass(compact)} resize-none\`}
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<input
|
|
181
|
+
type="text"
|
|
182
|
+
value={value ?? ''}
|
|
183
|
+
onChange={e => onChange(e.target.value)}
|
|
184
|
+
disabled={disabled}
|
|
185
|
+
required={required}
|
|
186
|
+
placeholder={\`\${humanizeFieldName(name)}\${typeStr ? \` (\${typeStr})\` : ''}\`}
|
|
187
|
+
className={inputClass(compact)}
|
|
188
|
+
/>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* EntitySelect — dropdown of existing entities, for UUID parameters
|
|
194
|
+
* that reference a model. Fetches \`GET /api/{resource}\` (static REST
|
|
195
|
+
* convention) and renders each entity by its display name.
|
|
196
|
+
*/
|
|
197
|
+
interface EntitySelectProps {
|
|
198
|
+
modelName: string;
|
|
199
|
+
value: string;
|
|
200
|
+
onChange: (value: string) => void;
|
|
201
|
+
compact?: boolean;
|
|
202
|
+
/** Filter entities by a foreign key value, e.g. { categoryId: 'abc-123' }.
|
|
203
|
+
* Used for cascading pickers where a second param depends on the first. */
|
|
204
|
+
filterBy?: Record<string, string>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function EntitySelect({ modelName, value, onChange, compact = false, filterBy }: EntitySelectProps) {
|
|
208
|
+
const [allEntities, setAllEntities] = useState<any[]>([]);
|
|
209
|
+
const resource = pluralize(modelName).toLowerCase();
|
|
210
|
+
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
fetch(\`\${API_BASE}/api/\${resource}\`)
|
|
213
|
+
.then(r => r.json())
|
|
214
|
+
.then(data => setAllEntities(Array.isArray(data) ? data : []))
|
|
215
|
+
.catch(() => setAllEntities([]));
|
|
216
|
+
}, [resource]);
|
|
217
|
+
|
|
218
|
+
const entities = filterBy
|
|
219
|
+
? allEntities.filter(e => {
|
|
220
|
+
for (const [fk, fkValue] of Object.entries(filterBy)) {
|
|
221
|
+
if (!fkValue) continue;
|
|
222
|
+
if (!(fk in e)) continue;
|
|
223
|
+
if (e[fk] !== fkValue) return false;
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
})
|
|
227
|
+
: allEntities;
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<select
|
|
231
|
+
value={value || ''}
|
|
232
|
+
onChange={e => onChange(e.target.value)}
|
|
233
|
+
className={inputClass(compact)}
|
|
234
|
+
>
|
|
235
|
+
<option value="">
|
|
236
|
+
Select {modelName}...{filterBy && Object.values(filterBy).some(v => v) ? \` (\${entities.length} matching)\` : ''}
|
|
237
|
+
</option>
|
|
238
|
+
{entities.map(entity => {
|
|
239
|
+
const id = entity.id;
|
|
240
|
+
return (
|
|
241
|
+
<option key={id} value={id}>
|
|
242
|
+
{getEntityDisplayName(entity)}{id ? \` (\${String(id).slice(0, 8)}...)\` : ''}
|
|
243
|
+
</option>
|
|
244
|
+
);
|
|
245
|
+
})}
|
|
246
|
+
</select>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function emitOperationExecutor(): string {
|
|
253
|
+
return `/**
|
|
254
|
+
* OperationExecutor — run a controller command or service operation
|
|
255
|
+
* against the backend. Renders a parameter form, an Execute button,
|
|
256
|
+
* and the result/error beneath.
|
|
257
|
+
*
|
|
258
|
+
* Inlined into this project by @specverse/realize (ReactAppStarter).
|
|
259
|
+
* Edit freely. Ported from app-demo's OperationExecutor.
|
|
260
|
+
*
|
|
261
|
+
* For source='service' ops: POST /api/services/{serviceName}/{operationName}
|
|
262
|
+
* For source='controller' ops: POST /api/{resource}/{operationName}, where
|
|
263
|
+
* resource is the pluralized-lowercase form of the controller's managed
|
|
264
|
+
* model (ControllerName minus 'Controller' suffix).
|
|
265
|
+
*/
|
|
266
|
+
import { useState, useMemo } from 'react';
|
|
267
|
+
import { FieldInput, EntitySelect } from './FieldInput';
|
|
268
|
+
import { OperationResultView } from './OperationResultView';
|
|
269
|
+
import { Play } from 'lucide-react';
|
|
270
|
+
|
|
271
|
+
const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
|
|
272
|
+
|
|
273
|
+
function pluralize(s: string): string {
|
|
274
|
+
if (/[^aeiou]y$/i.test(s)) return s.slice(0, -1) + 'ies';
|
|
275
|
+
if (/(s|x|z|ch|sh)$/i.test(s)) return s + 'es';
|
|
276
|
+
return s + 's';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface OperationExecutorProps {
|
|
280
|
+
/** The service (source='service') or controller (source='controller') name. */
|
|
281
|
+
serviceName: string;
|
|
282
|
+
operationName: string;
|
|
283
|
+
/** Parameter declarations from the spec. Supports both
|
|
284
|
+
* \`{ name: { type: 'String' } }\` and \`{ name: 'String' }\` shapes. */
|
|
285
|
+
parameters?: Record<string, any>;
|
|
286
|
+
/** Optional; currently unused but kept for forward compat with
|
|
287
|
+
* app-demo where requires gates the run. */
|
|
288
|
+
requires?: string[];
|
|
289
|
+
source?: 'service' | 'controller';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function OperationExecutor({
|
|
293
|
+
serviceName,
|
|
294
|
+
operationName,
|
|
295
|
+
parameters,
|
|
296
|
+
requires: _requires,
|
|
297
|
+
source = 'service',
|
|
298
|
+
}: OperationExecutorProps) {
|
|
299
|
+
const [params, setParams] = useState<Record<string, any>>({});
|
|
300
|
+
const [loading, setLoading] = useState(false);
|
|
301
|
+
const [result, setResult] = useState<any>(null);
|
|
302
|
+
const [error, setError] = useState<string | null>(null);
|
|
303
|
+
|
|
304
|
+
const paramDefs = useMemo(() => {
|
|
305
|
+
if (!parameters) return [];
|
|
306
|
+
const entries: Array<[string, any]> = Array.isArray(parameters)
|
|
307
|
+
? parameters.map((p: any) => [p.name, p])
|
|
308
|
+
: Object.entries(parameters);
|
|
309
|
+
if (entries.length === 0) return [];
|
|
310
|
+
|
|
311
|
+
return entries.map(([name, def]) => {
|
|
312
|
+
const typeStr = typeof def === 'string' ? def : def?.type || '';
|
|
313
|
+
const isUuid = typeStr.toLowerCase().includes('uuid');
|
|
314
|
+
let refModel: string | null = null;
|
|
315
|
+
if (isUuid && name.endsWith('Id')) {
|
|
316
|
+
const base = name.slice(0, -2);
|
|
317
|
+
refModel = base.charAt(0).toUpperCase() + base.slice(1);
|
|
318
|
+
}
|
|
319
|
+
return { name, type: typeStr, isUuid, refModel };
|
|
320
|
+
});
|
|
321
|
+
}, [parameters]);
|
|
322
|
+
|
|
323
|
+
const execute = async () => {
|
|
324
|
+
setLoading(true);
|
|
325
|
+
setError(null);
|
|
326
|
+
setResult(null);
|
|
327
|
+
try {
|
|
328
|
+
let url: string;
|
|
329
|
+
if (source === 'controller') {
|
|
330
|
+
// ControllerName → resource-plural-lowercase (PostController → posts)
|
|
331
|
+
const modelName = serviceName.replace(/Controller$/, '');
|
|
332
|
+
const resource = pluralize(modelName).toLowerCase();
|
|
333
|
+
url = \`\${API_BASE}/api/\${resource}/\${operationName}\`;
|
|
334
|
+
} else {
|
|
335
|
+
url = \`\${API_BASE}/api/services/\${serviceName}/\${operationName}\`;
|
|
336
|
+
}
|
|
337
|
+
const res = await fetch(url, {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
headers: { 'Content-Type': 'application/json' },
|
|
340
|
+
body: JSON.stringify(params),
|
|
341
|
+
});
|
|
342
|
+
const data = await res.json();
|
|
343
|
+
setResult(data);
|
|
344
|
+
if (!res.ok) {
|
|
345
|
+
setError(data?.message || data?.error || \`\${res.status} \${res.statusText}\`);
|
|
346
|
+
} else if (data && data.success === false) {
|
|
347
|
+
setError(data.error?.message || 'Operation failed');
|
|
348
|
+
}
|
|
349
|
+
} catch (err: any) {
|
|
350
|
+
setError(err?.message ?? String(err));
|
|
351
|
+
} finally {
|
|
352
|
+
setLoading(false);
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600 space-y-2">
|
|
358
|
+
{paramDefs.length > 0 ? (
|
|
359
|
+
paramDefs.map(param => {
|
|
360
|
+
// Cascading filter: if this param's model has a belongsTo to another
|
|
361
|
+
// already-filled param's model, filter by it.
|
|
362
|
+
let filterBy: Record<string, string> | undefined;
|
|
363
|
+
if (param.refModel) {
|
|
364
|
+
for (const other of paramDefs) {
|
|
365
|
+
if (other === param || !other.refModel) continue;
|
|
366
|
+
const fk = other.refModel.charAt(0).toLowerCase() + other.refModel.slice(1) + 'Id';
|
|
367
|
+
if (params[other.name]) {
|
|
368
|
+
filterBy = { ...filterBy, [fk]: params[other.name] };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return (
|
|
373
|
+
<div key={param.name} className="flex items-center gap-2">
|
|
374
|
+
<label
|
|
375
|
+
className="text-xs text-gray-500 dark:text-gray-400 w-24 flex-shrink-0 truncate"
|
|
376
|
+
title={param.name}
|
|
377
|
+
>
|
|
378
|
+
{param.name}
|
|
379
|
+
</label>
|
|
380
|
+
{param.refModel ? (
|
|
381
|
+
<EntitySelect
|
|
382
|
+
modelName={param.refModel}
|
|
383
|
+
value={params[param.name] || ''}
|
|
384
|
+
onChange={v => setParams(prev => ({ ...prev, [param.name]: v }))}
|
|
385
|
+
filterBy={filterBy}
|
|
386
|
+
compact
|
|
387
|
+
/>
|
|
388
|
+
) : (
|
|
389
|
+
<FieldInput
|
|
390
|
+
name={param.name}
|
|
391
|
+
typeStr={param.type}
|
|
392
|
+
value={params[param.name]}
|
|
393
|
+
onChange={v => setParams(prev => ({ ...prev, [param.name]: v }))}
|
|
394
|
+
compact
|
|
395
|
+
/>
|
|
396
|
+
)}
|
|
397
|
+
</div>
|
|
398
|
+
);
|
|
399
|
+
})
|
|
400
|
+
) : (
|
|
401
|
+
<p className="text-xs text-gray-400 italic">No parameters defined</p>
|
|
402
|
+
)}
|
|
403
|
+
|
|
404
|
+
<button
|
|
405
|
+
onClick={execute}
|
|
406
|
+
disabled={loading}
|
|
407
|
+
className="w-full px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded flex items-center justify-center gap-1 disabled:opacity-50"
|
|
408
|
+
>
|
|
409
|
+
<Play className="w-3 h-3" />
|
|
410
|
+
{loading ? 'Executing...' : \`Execute \${operationName}\`}
|
|
411
|
+
</button>
|
|
412
|
+
|
|
413
|
+
{error && !result && (
|
|
414
|
+
<pre className="p-2 text-xs text-red-400 bg-red-50 dark:bg-red-900/20 rounded overflow-x-auto">
|
|
415
|
+
{error}
|
|
416
|
+
</pre>
|
|
417
|
+
)}
|
|
418
|
+
{result && <OperationResultView result={result} />}
|
|
419
|
+
</div>
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function emitOperationResultView(): string {
|
|
426
|
+
return `/**
|
|
427
|
+
* OperationResultView — formatted display of an operation's result.
|
|
428
|
+
*
|
|
429
|
+
* Inlined into this project by @specverse/realize (ReactAppStarter).
|
|
430
|
+
* Edit freely. Ported from app-demo's OperationResultView.
|
|
431
|
+
*
|
|
432
|
+
* Handles three shapes:
|
|
433
|
+
* - Success with data → JSON pretty-print
|
|
434
|
+
* - Success without data → "Operation succeeded" banner
|
|
435
|
+
* - Error { success: false, error: ... } → error banner + details
|
|
436
|
+
* - Unexpected → raw JSON
|
|
437
|
+
*/
|
|
438
|
+
import { CheckCircle, XCircle } from 'lucide-react';
|
|
439
|
+
|
|
440
|
+
interface OperationResultViewProps {
|
|
441
|
+
result: any;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function JsonDisplay({ value }: { value: any }) {
|
|
445
|
+
return (
|
|
446
|
+
<pre className="p-3 text-xs bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded overflow-x-auto text-gray-900 dark:text-gray-100">
|
|
447
|
+
{JSON.stringify(value, null, 2)}
|
|
448
|
+
</pre>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function OperationResultView({ result }: OperationResultViewProps) {
|
|
453
|
+
// Explicit error envelope: { success: false, error: ... }
|
|
454
|
+
if (result && typeof result === 'object' && result.success === false) {
|
|
455
|
+
const errMsg =
|
|
456
|
+
(typeof result.error === 'object' ? result.error?.message : result.error) ||
|
|
457
|
+
'Operation failed';
|
|
458
|
+
return (
|
|
459
|
+
<div className="space-y-2">
|
|
460
|
+
<div className="flex items-start gap-2 p-2 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-xs">
|
|
461
|
+
<XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
|
462
|
+
<div>
|
|
463
|
+
<div className="font-medium">Operation failed</div>
|
|
464
|
+
<div className="mt-1">{errMsg}</div>
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
{result.error && typeof result.error === 'object' && <JsonDisplay value={result.error} />}
|
|
468
|
+
</div>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Explicit success envelope: { success: true, data?: ... }
|
|
473
|
+
if (result && typeof result === 'object' && result.success === true) {
|
|
474
|
+
return (
|
|
475
|
+
<div className="space-y-2">
|
|
476
|
+
<div className="flex items-center gap-2 p-2 rounded bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs">
|
|
477
|
+
<CheckCircle className="w-4 h-4 flex-shrink-0" />
|
|
478
|
+
<span className="font-medium">Operation succeeded</span>
|
|
479
|
+
</div>
|
|
480
|
+
{result.data !== undefined && <JsonDisplay value={result.data} />}
|
|
481
|
+
</div>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// No envelope — just show whatever came back (most REST endpoints).
|
|
486
|
+
return (
|
|
487
|
+
<div className="space-y-2">
|
|
488
|
+
<div className="flex items-center gap-2 p-2 rounded bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs">
|
|
489
|
+
<CheckCircle className="w-4 h-4 flex-shrink-0" />
|
|
490
|
+
<span className="font-medium">Operation completed</span>
|
|
491
|
+
</div>
|
|
492
|
+
{result !== undefined && result !== null && <JsonDisplay value={result} />}
|
|
493
|
+
</div>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
`;
|
|
497
|
+
}
|