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