@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
|
@@ -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
|
}
|
|
@@ -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
|
}
|
|
@@ -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. */}
|
|
@@ -3,6 +3,21 @@ function pluralize(s) {
|
|
|
3
3
|
if (/(s|x|z|ch|sh)$/.test(s)) return s + "es";
|
|
4
4
|
return s + "s";
|
|
5
5
|
}
|
|
6
|
+
function extractLifecycles(model) {
|
|
7
|
+
const out = [];
|
|
8
|
+
const lifecycles = model?.lifecycles ?? {};
|
|
9
|
+
for (const [name, def] of Object.entries(lifecycles)) {
|
|
10
|
+
if (!def || typeof def !== "object") continue;
|
|
11
|
+
let states = [];
|
|
12
|
+
if (Array.isArray(def.states)) {
|
|
13
|
+
states = def.states.map((s) => typeof s === "string" ? s : s?.name ?? s?.id ?? "").filter(Boolean);
|
|
14
|
+
} else if (typeof def.flow === "string") {
|
|
15
|
+
states = def.flow.split(/\s*(?:->|,)\s*/).map((s) => s.trim()).filter(Boolean);
|
|
16
|
+
}
|
|
17
|
+
if (states.length > 0) out.push({ name, states });
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
6
21
|
async function generate(context) {
|
|
7
22
|
const models = Object.keys(context.spec.models ?? {});
|
|
8
23
|
const importsAndTypes = `/**
|
|
@@ -20,10 +35,13 @@ ${models.map((m) => `import type { ${m} } from '../types/api';`).join("\n")}
|
|
|
20
35
|
const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
|
|
21
36
|
|
|
22
37
|
async function fetchJSON<T = unknown>(url: string, init?: RequestInit): Promise<T> {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
// Only declare Content-Type: application/json when there's actually a
|
|
39
|
+
// body. Fastify's default JSON parser rejects empty-body requests that
|
|
40
|
+
// claim JSON (FST_ERR_CTP_EMPTY_JSON_BODY) \u2014 so DELETE / GET without
|
|
41
|
+
// bodies must NOT send the header.
|
|
42
|
+
const headers: Record<string, string> = {};
|
|
43
|
+
if (init?.body != null) headers['Content-Type'] = 'application/json';
|
|
44
|
+
const res = await fetch(url, { ...init, headers: { ...headers, ...init?.headers } });
|
|
27
45
|
if (!res.ok) {
|
|
28
46
|
throw new Error(\`\${res.status} \${res.statusText} \u2014 \${url}\`);
|
|
29
47
|
}
|
|
@@ -31,15 +49,36 @@ async function fetchJSON<T = unknown>(url: string, init?: RequestInit): Promise<
|
|
|
31
49
|
return (await res.json()) as T;
|
|
32
50
|
}
|
|
33
51
|
`;
|
|
34
|
-
const hookBlocks = models.map((m) => generateModelHooks(m)).join("\n\n");
|
|
52
|
+
const hookBlocks = models.map((m) => generateModelHooks(m, context.spec.models?.[m])).join("\n\n");
|
|
35
53
|
return importsAndTypes + "\n" + hookBlocks + "\n";
|
|
36
54
|
}
|
|
37
|
-
function generateModelHooks(model) {
|
|
55
|
+
function generateModelHooks(model, modelDef) {
|
|
38
56
|
const plural = pluralize(model);
|
|
39
57
|
const resource = plural.toLowerCase();
|
|
40
|
-
const
|
|
58
|
+
const lifecycles = extractLifecycles(modelDef);
|
|
59
|
+
const hasLifecycle = lifecycles.length > 0;
|
|
60
|
+
const lifecycleBlock = hasLifecycle ? `
|
|
61
|
+
export const ${model.toUpperCase()}_LIFECYCLES = ${JSON.stringify(
|
|
62
|
+
Object.fromEntries(lifecycles.map((l) => [l.name, { states: l.states }])),
|
|
63
|
+
null,
|
|
64
|
+
2
|
|
65
|
+
)} as const;
|
|
66
|
+
` : "";
|
|
67
|
+
const evolveHook = hasLifecycle ? `
|
|
68
|
+
export function useEvolve${model}Mutation() {
|
|
69
|
+
const qc = useQueryClient();
|
|
70
|
+
return useMutation({
|
|
71
|
+
mutationFn: ({ id, toState, lifecycleName }: { id: string | number; toState: string; lifecycleName?: string }) =>
|
|
72
|
+
fetchJSON<${model}>(\`\${API_BASE}/api/${resource}/\${id}/evolve\`, {
|
|
73
|
+
method: 'PATCH',
|
|
74
|
+
body: JSON.stringify({ toState, lifecycleName }),
|
|
75
|
+
}),
|
|
76
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ['${resource}'] }),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
` : "";
|
|
41
80
|
return `// \u2500\u2500\u2500 ${model} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
42
|
-
|
|
81
|
+
${lifecycleBlock}
|
|
43
82
|
export function use${plural}Query() {
|
|
44
83
|
return useQuery({
|
|
45
84
|
queryKey: ['${resource}'],
|
|
@@ -72,7 +111,7 @@ export function useUpdate${model}Mutation() {
|
|
|
72
111
|
return useMutation({
|
|
73
112
|
mutationFn: ({ id, data }: { id: string | number; data: Partial<${model}> }) =>
|
|
74
113
|
fetchJSON<${model}>(\`\${API_BASE}/api/${resource}/\${id}\`, {
|
|
75
|
-
method: '
|
|
114
|
+
method: 'PUT',
|
|
76
115
|
body: JSON.stringify(data),
|
|
77
116
|
}),
|
|
78
117
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['${resource}'] }),
|
|
@@ -86,7 +125,7 @@ export function useDelete${model}Mutation() {
|
|
|
86
125
|
fetchJSON<void>(\`\${API_BASE}/api/${resource}/\${id}\`, { method: 'DELETE' }),
|
|
87
126
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['${resource}'] }),
|
|
88
127
|
});
|
|
89
|
-
}`;
|
|
128
|
+
}${evolveHook}`;
|
|
90
129
|
}
|
|
91
130
|
var stdin_default = generate;
|
|
92
131
|
export {
|