@specverse/engines 4.2.0 → 4.2.1
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/assets/templates/default/specs/main.specly +65 -0
- package/dist/libs/instance-factories/CURVED-INTERFACE.md +278 -0
- package/dist/libs/instance-factories/README.md +73 -0
- package/dist/libs/instance-factories/applications/README.md +51 -0
- package/dist/libs/instance-factories/applications/generic-app.yaml +52 -0
- package/dist/libs/instance-factories/applications/react-app-runtime.yaml +139 -0
- package/dist/libs/instance-factories/applications/react-app-starter.yaml +143 -0
- package/dist/libs/instance-factories/applications/templates/react/env-example-generator.js +24 -2
- package/dist/libs/instance-factories/applications/templates/react/vite-config-generator.js +54 -33
- package/dist/libs/instance-factories/applications/templates/react-starter/README.md +211 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/api-types-starter-generator.js +69 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +1 -1
- package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +40 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +11 -3
- package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +18 -16
- package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +50 -23
- package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +9 -3
- package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +17 -7
- package/dist/libs/instance-factories/applications/templates/react-starter/orchestrator.js +16 -5
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/dashboard.tsx.template +49 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +96 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +116 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +74 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +95 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +26 -1
- package/dist/libs/instance-factories/archived/fastify-prisma.yaml +104 -0
- package/dist/libs/instance-factories/cli/README.md +43 -0
- package/dist/libs/instance-factories/cli/commander-js.yaml +55 -0
- package/dist/libs/instance-factories/cli/templates/commander/command-generator.js +49 -1
- package/dist/libs/instance-factories/communication/README.md +47 -0
- package/dist/libs/instance-factories/communication/event-emitter.yaml +60 -0
- package/dist/libs/instance-factories/communication/rabbitmq-events.yaml +87 -0
- package/dist/libs/instance-factories/controllers/README.md +42 -0
- package/dist/libs/instance-factories/controllers/fastify.yaml +139 -0
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +29 -2
- package/dist/libs/instance-factories/infrastructure/README.md +29 -0
- package/dist/libs/instance-factories/infrastructure/docker-k8s.yaml +61 -0
- package/dist/libs/instance-factories/orms/README.md +54 -0
- package/dist/libs/instance-factories/orms/prisma.yaml +89 -0
- package/dist/libs/instance-factories/orms/templates/prisma/schema-generator.js +2 -2
- package/dist/libs/instance-factories/scaffolding/README.md +49 -0
- package/dist/libs/instance-factories/scaffolding/generic-scaffold.yaml +65 -0
- package/dist/libs/instance-factories/sdks/README.md +28 -0
- package/dist/libs/instance-factories/sdks/python-sdk.yaml +66 -0
- package/dist/libs/instance-factories/sdks/typescript-sdk.yaml +59 -0
- package/dist/libs/instance-factories/services/README.md +55 -0
- package/dist/libs/instance-factories/services/prisma-services.yaml +71 -0
- package/dist/libs/instance-factories/storage/README.md +34 -0
- package/dist/libs/instance-factories/storage/mongodb.yaml +79 -0
- package/dist/libs/instance-factories/storage/postgresql.yaml +75 -0
- package/dist/libs/instance-factories/storage/redis.yaml +79 -0
- package/dist/libs/instance-factories/testing/README.md +40 -0
- package/dist/libs/instance-factories/testing/vitest-tests.yaml +63 -0
- package/dist/libs/instance-factories/tools/README.md +70 -0
- package/dist/libs/instance-factories/tools/mcp.yaml +36 -0
- package/dist/libs/instance-factories/tools/vscode.yaml +35 -0
- package/dist/libs/instance-factories/validation/README.md +38 -0
- package/dist/libs/instance-factories/validation/zod.yaml +56 -0
- package/dist/realize/engines/code-generator.d.ts.map +1 -1
- package/dist/realize/engines/code-generator.js +3 -0
- package/dist/realize/engines/code-generator.js.map +1 -1
- package/libs/instance-factories/applications/react-app-starter.yaml +10 -17
- package/libs/instance-factories/applications/templates/react/env-example-generator.ts +24 -2
- package/libs/instance-factories/applications/templates/react/vite-config-generator.ts +54 -33
- package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +5 -4
- package/libs/instance-factories/applications/templates/react-starter/__tests__/form-body-composer.test.ts +18 -5
- package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +83 -62
- package/libs/instance-factories/applications/templates/react-starter/api-types-starter-generator.ts +98 -0
- package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +1 -1
- package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +82 -0
- package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +20 -5
- package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +33 -33
- package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +107 -30
- package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +9 -3
- package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +34 -8
- package/libs/instance-factories/applications/templates/react-starter/orchestrator.ts +41 -26
- package/libs/instance-factories/applications/templates/react-starter/skeletons/dashboard.tsx.template +2 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +2 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +2 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +2 -0
- package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +124 -0
- package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +58 -0
- package/libs/instance-factories/cli/templates/commander/command-generator.ts +49 -1
- package/libs/instance-factories/controllers/fastify.yaml +7 -0
- package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +36 -2
- package/libs/instance-factories/orms/templates/prisma/schema-generator.ts +11 -4
- package/package.json +1 -1
package/libs/instance-factories/applications/templates/react-starter/api-types-starter-generator.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API types generator for ReactAppStarter
|
|
3
|
+
*
|
|
4
|
+
* Emits per-model TypeScript interfaces that the starter's view
|
|
5
|
+
* skeletons import (`import type { Task } from '../types/api'`). The
|
|
6
|
+
* shape mirrors the model's attributes as declared in the spec. No
|
|
7
|
+
* runtime-specific types (those live in ReactAppRuntime's api-types
|
|
8
|
+
* generator).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ApiTypesStarterContext {
|
|
12
|
+
spec: {
|
|
13
|
+
models?: Record<string, any>;
|
|
14
|
+
};
|
|
15
|
+
manifest?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Map SpecVerse attribute types to TypeScript types.
|
|
20
|
+
*
|
|
21
|
+
* Strings vs unions: if the attribute has a `values` enum (e.g.
|
|
22
|
+
* status: String values=[draft,published]), emit a union type.
|
|
23
|
+
*/
|
|
24
|
+
function mapAttrType(attr: any): string {
|
|
25
|
+
const t = typeof attr === 'string'
|
|
26
|
+
? attr.split(/\s+/)[0]
|
|
27
|
+
: attr?.type;
|
|
28
|
+
const values = attr?.values;
|
|
29
|
+
if (Array.isArray(values) && values.length > 0) {
|
|
30
|
+
return values.map((v: string) => JSON.stringify(v)).join(' | ');
|
|
31
|
+
}
|
|
32
|
+
switch ((t || '').toLowerCase()) {
|
|
33
|
+
case 'string':
|
|
34
|
+
case 'text':
|
|
35
|
+
case 'uuid':
|
|
36
|
+
case 'email':
|
|
37
|
+
case 'url':
|
|
38
|
+
return 'string';
|
|
39
|
+
case 'int':
|
|
40
|
+
case 'integer':
|
|
41
|
+
case 'float':
|
|
42
|
+
case 'number':
|
|
43
|
+
case 'decimal':
|
|
44
|
+
return 'number';
|
|
45
|
+
case 'boolean':
|
|
46
|
+
case 'bool':
|
|
47
|
+
return 'boolean';
|
|
48
|
+
case 'datetime':
|
|
49
|
+
case 'date':
|
|
50
|
+
case 'time':
|
|
51
|
+
case 'timestamp':
|
|
52
|
+
return 'string';
|
|
53
|
+
case 'json':
|
|
54
|
+
return 'Record<string, unknown>';
|
|
55
|
+
default:
|
|
56
|
+
return 'unknown';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isRequired(attr: any): boolean {
|
|
61
|
+
if (typeof attr === 'string') return /\brequired\b/.test(attr);
|
|
62
|
+
return !!attr?.required;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function generate(context: ApiTypesStarterContext): Promise<string> {
|
|
66
|
+
const models = context.spec.models ?? {};
|
|
67
|
+
const modelNames = Object.keys(models);
|
|
68
|
+
|
|
69
|
+
const header = `/**
|
|
70
|
+
* API types (ReactAppStarter)
|
|
71
|
+
*
|
|
72
|
+
* Per-model interfaces generated from the spec's model definitions.
|
|
73
|
+
* Safe to edit — extend these when your UI needs more than the spec
|
|
74
|
+
* describes (e.g. optimistic fields, denormalized joins).
|
|
75
|
+
*/
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
const interfaces = modelNames.map(name => {
|
|
79
|
+
const model = models[name] || {};
|
|
80
|
+
const attrs = model.attributes || {};
|
|
81
|
+
const attrKeys = Object.keys(attrs);
|
|
82
|
+
|
|
83
|
+
const fields = attrKeys.map(attrName => {
|
|
84
|
+
const attr = attrs[attrName];
|
|
85
|
+
const tsType = mapAttrType(attr);
|
|
86
|
+
const optional = isRequired(attr) ? '' : '?';
|
|
87
|
+
return ` ${attrName}${optional}: ${tsType};`;
|
|
88
|
+
}).join('\n');
|
|
89
|
+
|
|
90
|
+
return `export interface ${name} {
|
|
91
|
+
${fields || ' [key: string]: unknown;'}
|
|
92
|
+
}`;
|
|
93
|
+
}).join('\n\n');
|
|
94
|
+
|
|
95
|
+
return header + '\n' + interfaces + '\n';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default generate;
|
|
@@ -107,7 +107,7 @@ function buildNavEntries(models: string[]): string {
|
|
|
107
107
|
</div>
|
|
108
108
|
<button type="button" onClick={() => select('${m}', 'list')} className="${navButtonCls('list')}">List</button>
|
|
109
109
|
<button type="button" onClick={() => select('${m}', 'dashboard')} className="${navButtonCls('dashboard')}">Dashboard</button>
|
|
110
|
-
<button type="button" onClick={() => select('${m}', 'form')} className="${navButtonCls('form')}">
|
|
110
|
+
<button type="button" onClick={() => select('${m}', 'form')} className="${navButtonCls('form')}">Form</button>
|
|
111
111
|
</div>`).join('\n');
|
|
112
112
|
}
|
|
113
113
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helper: extract belongsTo relationships from a model.
|
|
3
|
+
*
|
|
4
|
+
* Used by both the form-body composer (emits <select> dropdowns) and
|
|
5
|
+
* view-emitter (generates the import + hook wiring for those
|
|
6
|
+
* dropdowns). Keeping this in one place means form JSX and skeleton
|
|
7
|
+
* substitutions can't drift.
|
|
8
|
+
*
|
|
9
|
+
* Supports both shapes a parsed spec can use:
|
|
10
|
+
* - Convention string: "belongsTo User cascade"
|
|
11
|
+
* - Structured object: { type: "belongsTo", target: "User" }
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ModelSpec } from './view-emitter.js';
|
|
15
|
+
|
|
16
|
+
export interface BelongsToRel {
|
|
17
|
+
/** Relationship name — e.g. "owner", "assignee". */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Target model name — e.g. "User". */
|
|
20
|
+
target: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function extractBelongsToTargets(model: ModelSpec): BelongsToRel[] {
|
|
24
|
+
const rels = (model.relationships ?? {}) as Record<string, unknown>;
|
|
25
|
+
const out: BelongsToRel[] = [];
|
|
26
|
+
|
|
27
|
+
for (const [name, rawDef] of Object.entries(rels)) {
|
|
28
|
+
const parsed = parseBelongsTo(rawDef);
|
|
29
|
+
if (parsed) out.push({ name, target: parsed });
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseBelongsTo(def: unknown): string | null {
|
|
35
|
+
// Convention string: "belongsTo User cascade" / "belongsTo User"
|
|
36
|
+
if (typeof def === 'string') {
|
|
37
|
+
const parts = def.trim().split(/\s+/);
|
|
38
|
+
if (parts[0] === 'belongsTo' && parts[1]) return parts[1];
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
// Structured: { type: "belongsTo", target: "User" } — support all
|
|
42
|
+
// of the field names the parsed spec can use ("target", "to",
|
|
43
|
+
// "model", "targetModel"). The normalizer upstream is inconsistent
|
|
44
|
+
// and normalizing it there is out of scope for this composer.
|
|
45
|
+
if (def && typeof def === 'object') {
|
|
46
|
+
const o = def as { type?: string; target?: string; to?: string; model?: string; targetModel?: string };
|
|
47
|
+
if (o.type === 'belongsTo') {
|
|
48
|
+
return o.target || o.to || o.model || o.targetModel || null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Minimal English pluralizer, matching view-emitter's and
|
|
56
|
+
* use-api-hooks-starter-generator's convention. Centralizing it here
|
|
57
|
+
* keeps the generated hook names (useUsersQuery) aligned with what
|
|
58
|
+
* the emitter imports.
|
|
59
|
+
*/
|
|
60
|
+
export function pluralize(s: string): string {
|
|
61
|
+
if (/[^aeiou]y$/i.test(s)) return s.slice(0, -1) + 'ies';
|
|
62
|
+
if (/(s|x|z|ch|sh)$/i.test(s)) return s + 'es';
|
|
63
|
+
return s + 's';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build a map from FK column name → belongsTo relationship. Used by
|
|
68
|
+
* every view composer (list, detail, dashboard, form) to detect when
|
|
69
|
+
* an attribute is really a foreign key and should render as a resolved
|
|
70
|
+
* display name, not a raw UUID.
|
|
71
|
+
*
|
|
72
|
+
* Convention: a belongsTo relationship named `owner` pointing at `User`
|
|
73
|
+
* implies a column `ownerId: UUID` on the owning model. This function
|
|
74
|
+
* materializes that convention.
|
|
75
|
+
*/
|
|
76
|
+
export function buildFKMap(model: ModelSpec): Map<string, BelongsToRel> {
|
|
77
|
+
const map = new Map<string, BelongsToRel>();
|
|
78
|
+
for (const rel of extractBelongsToTargets(model)) {
|
|
79
|
+
map.set(`${rel.name}Id`, rel);
|
|
80
|
+
}
|
|
81
|
+
return map;
|
|
82
|
+
}
|
package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { METADATA_FIELDS } from '@specverse/runtime/views/core';
|
|
14
14
|
import type { EmitContext, ModelSpec } from './view-emitter.js';
|
|
15
|
+
import { buildFKMap } from './belongs-to.js';
|
|
15
16
|
|
|
16
17
|
const METADATA_FIELD_NAMES = new Set(METADATA_FIELDS);
|
|
17
18
|
|
|
@@ -19,6 +20,7 @@ export function composeDashboardBody(context: EmitContext): string {
|
|
|
19
20
|
const modelName = context.model.name;
|
|
20
21
|
const previewColumns = inferPreviewColumns(context.model);
|
|
21
22
|
const enumFields = inferEnumFields(context.model);
|
|
23
|
+
const fkMap = buildFKMap(context.model);
|
|
22
24
|
|
|
23
25
|
const lines: string[] = [];
|
|
24
26
|
|
|
@@ -55,9 +57,11 @@ export function composeDashboardBody(context: EmitContext): string {
|
|
|
55
57
|
lines.push(' <thead>');
|
|
56
58
|
lines.push(' <tr>');
|
|
57
59
|
for (const col of previewColumns) {
|
|
60
|
+
const fk = fkMap.get(col);
|
|
61
|
+
const headerLabel = humanize(fk ? fk.name : col);
|
|
58
62
|
lines.push(
|
|
59
63
|
` <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">` +
|
|
60
|
-
|
|
64
|
+
headerLabel +
|
|
61
65
|
`</th>`
|
|
62
66
|
);
|
|
63
67
|
}
|
|
@@ -71,9 +75,13 @@ export function composeDashboardBody(context: EmitContext): string {
|
|
|
71
75
|
lines.push(' className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"');
|
|
72
76
|
lines.push(' >');
|
|
73
77
|
for (const col of previewColumns) {
|
|
78
|
+
const fk = fkMap.get(col);
|
|
79
|
+
const expr = fk
|
|
80
|
+
? `{resolveEntityDisplayName((item as any).${col}, ${fk.name}Options)}`
|
|
81
|
+
: `{String((item as any).${col} ?? '')}`;
|
|
74
82
|
lines.push(
|
|
75
83
|
` <td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">` +
|
|
76
|
-
|
|
84
|
+
`${expr}</td>`
|
|
77
85
|
);
|
|
78
86
|
}
|
|
79
87
|
lines.push(' </tr>');
|
|
@@ -161,9 +169,16 @@ function inferEnumFields(model: ModelSpec): EnumField[] {
|
|
|
161
169
|
|
|
162
170
|
function inferPreviewColumns(model: ModelSpec): string[] {
|
|
163
171
|
const attrs = model.attributes ?? {};
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
172
|
+
const attrCols = Object.keys(attrs).filter(n => !METADATA_FIELD_NAMES.has(n));
|
|
173
|
+
|
|
174
|
+
// Include belongsTo FK columns so the preview shows "who does this
|
|
175
|
+
// belong to" at a glance. Matches the list-view convention.
|
|
176
|
+
const fkCols = [...buildFKMap(model).keys()].filter(k => !attrCols.includes(k));
|
|
177
|
+
|
|
178
|
+
// FKs are always useful at-a-glance, so keep them all. Cap the
|
|
179
|
+
// combined set at 5 to keep the preview table readable without
|
|
180
|
+
// crowding out the "who" columns.
|
|
181
|
+
return [...fkCols, ...attrCols].slice(0, Math.max(5, fkCols.length + 1));
|
|
167
182
|
}
|
|
168
183
|
|
|
169
184
|
function humanize(name: string): string {
|
package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts
CHANGED
|
@@ -2,24 +2,18 @@
|
|
|
2
2
|
* Detail-view body composer for ReactAppStarter
|
|
3
3
|
*
|
|
4
4
|
* Renders the interior of a detail view as a JSX-safe definition list:
|
|
5
|
-
* business fields first (prominent),
|
|
5
|
+
* business fields first (prominent), belongsTo relationships resolved
|
|
6
|
+
* to display names in a dedicated section, metadata fields last (muted).
|
|
6
7
|
* The outer card + actions live in `skeletons/detail.tsx.template`.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* of the adapter's atomic components, and forcing it through a `card`
|
|
12
|
-
* adapter would add structure without clarifying anything in the
|
|
13
|
-
* generated code.
|
|
14
|
-
*
|
|
15
|
-
* BelongsTo relationships: today we emit the raw FK id. A future pass
|
|
16
|
-
* will resolve FK → entity display name (matches runtime's
|
|
17
|
-
* getEntityDisplayName). Marked with a TODO comment in the output so
|
|
18
|
-
* users inspecting the generated file see the hook.
|
|
9
|
+
* belongsTo FK columns are resolved via `resolveEntityDisplayName`
|
|
10
|
+
* using option arrays (${relName}Options) wired in by view-emitter's
|
|
11
|
+
* RELATED_HOOKS substitution.
|
|
19
12
|
*/
|
|
20
13
|
|
|
21
14
|
import { METADATA_FIELDS } from '@specverse/runtime/views/core';
|
|
22
15
|
import type { EmitContext, ModelSpec } from './view-emitter.js';
|
|
16
|
+
import { extractBelongsToTargets, type BelongsToRel } from './belongs-to.js';
|
|
23
17
|
|
|
24
18
|
/**
|
|
25
19
|
* Metadata attribute names rendered in a muted style. Sourced from
|
|
@@ -29,8 +23,12 @@ import type { EmitContext, ModelSpec } from './view-emitter.js';
|
|
|
29
23
|
const METADATA_FIELD_NAMES = new Set(METADATA_FIELDS);
|
|
30
24
|
|
|
31
25
|
export function composeDetailBody(context: EmitContext): string {
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
// FK columns are excluded from business so we don't emit the raw
|
|
27
|
+
// UUID next to the resolved name. They surface in the belongsTo
|
|
28
|
+
// section instead, rendered via resolveEntityDisplayName.
|
|
29
|
+
const belongsTo = extractBelongsToTargets(context.model);
|
|
30
|
+
const fkExcludes = new Set(belongsTo.map(r => `${r.name}Id`));
|
|
31
|
+
const { business, metadata } = partitionFields(context.model, fkExcludes);
|
|
34
32
|
|
|
35
33
|
const lines: string[] = [];
|
|
36
34
|
lines.push('<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">');
|
|
@@ -41,18 +39,15 @@ export function composeDetailBody(context: EmitContext): string {
|
|
|
41
39
|
lines.push(...renderField(field, { muted: false }));
|
|
42
40
|
}
|
|
43
41
|
lines.push(' </dl>');
|
|
44
|
-
} else {
|
|
42
|
+
} else if (belongsTo.length === 0) {
|
|
45
43
|
lines.push(' <p className="text-sm text-gray-400">No business fields defined for this model.</p>');
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
if (belongsTo.length > 0) {
|
|
49
47
|
lines.push('');
|
|
50
|
-
lines.push(' {/* TODO: resolve FK ids → related entity display names.');
|
|
51
|
-
lines.push(' See @specverse/runtime/views/core/entity-display for the');
|
|
52
|
-
lines.push(' canonical resolver, or load the related record in a hook. */}');
|
|
53
48
|
lines.push(' <dl className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 border-t border-gray-200 dark:border-gray-700 pt-4">');
|
|
54
49
|
for (const rel of belongsTo) {
|
|
55
|
-
lines.push(...
|
|
50
|
+
lines.push(...renderRelationshipField(rel));
|
|
56
51
|
}
|
|
57
52
|
lines.push(' </dl>');
|
|
58
53
|
}
|
|
@@ -76,11 +71,15 @@ interface Field {
|
|
|
76
71
|
label?: string;
|
|
77
72
|
}
|
|
78
73
|
|
|
79
|
-
function partitionFields(
|
|
74
|
+
function partitionFields(
|
|
75
|
+
model: ModelSpec,
|
|
76
|
+
exclude: Set<string> = new Set(),
|
|
77
|
+
): { business: Field[]; metadata: Field[] } {
|
|
80
78
|
const attrs = Object.keys(model.attributes ?? {});
|
|
81
79
|
const business: Field[] = [];
|
|
82
80
|
const metadata: Field[] = [];
|
|
83
81
|
for (const name of attrs) {
|
|
82
|
+
if (exclude.has(name)) continue;
|
|
84
83
|
const field = { name, label: humanize(name) };
|
|
85
84
|
if (METADATA_FIELD_NAMES.has(name)) {
|
|
86
85
|
metadata.push(field);
|
|
@@ -92,20 +91,21 @@ function partitionFields(model: ModelSpec): { business: Field[]; metadata: Field
|
|
|
92
91
|
}
|
|
93
92
|
|
|
94
93
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* explicitly in its own section so the generated UI separates "this
|
|
99
|
-
* entity's data" from "related entities."
|
|
94
|
+
* Render a belongsTo row as a label + resolved display name. The
|
|
95
|
+
* options array (${relName}Options) is populated by the hook call
|
|
96
|
+
* wired into the detail skeleton via view-emitter's RELATED_HOOKS.
|
|
100
97
|
*/
|
|
101
|
-
function
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
98
|
+
function renderRelationshipField(rel: BelongsToRel): string[] {
|
|
99
|
+
const label = humanize(rel.name);
|
|
100
|
+
const fkName = `${rel.name}Id`;
|
|
101
|
+
const optionsVar = `${rel.name}Options`;
|
|
102
|
+
return [
|
|
103
|
+
' <div>',
|
|
104
|
+
` <dt className="text-sm font-medium text-gray-500 dark:text-gray-400">${label}</dt>`,
|
|
105
|
+
` <dd className="mt-1 text-sm text-gray-900 dark:text-gray-100 break-words">` +
|
|
106
|
+
`{resolveEntityDisplayName((item as any).${fkName}, ${optionsVar})}</dd>`,
|
|
107
|
+
' </div>',
|
|
108
|
+
];
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
function renderField(field: Field, opts: { muted: boolean }): string[] {
|
|
@@ -10,15 +10,16 @@
|
|
|
10
10
|
* Auto-generated fields (id, timestamps, `auto=...` markers) are
|
|
11
11
|
* omitted — the backend assigns them.
|
|
12
12
|
*
|
|
13
|
-
* Relationship fields (belongsTo) are emitted as
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* Relationship fields (belongsTo) are emitted as <select> dropdowns
|
|
14
|
+
* backed by the related model's list query. The corresponding hook
|
|
15
|
+
* calls and imports are wired by view-emitter via the RELATED_HOOKS /
|
|
16
|
+
* RELATED_IMPORTS substitutions — this composer only emits the JSX
|
|
17
|
+
* that consumes the `${relName}Options` variable.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { METADATA_FIELDS } from '@specverse/runtime/views/core';
|
|
21
21
|
import type { EmitContext, ModelSpec } from './view-emitter.js';
|
|
22
|
+
import { extractBelongsToTargets, type BelongsToRel } from './belongs-to.js';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Attribute names the backend always generates — never render as
|
|
@@ -32,11 +33,12 @@ const AUTO_GENERATED_FIELD_NAMES = new Set(METADATA_FIELDS);
|
|
|
32
33
|
* Compose the form body as JSX-safe source. Dropped at `{{BODY}}`.
|
|
33
34
|
*/
|
|
34
35
|
export function composeFormBody(context: EmitContext): string {
|
|
35
|
-
const belongsTo =
|
|
36
|
+
const belongsTo = extractBelongsToTargets(context.model);
|
|
36
37
|
// FK columns shadowed by a belongsTo relationship are emitted in the
|
|
37
|
-
// belongsTo section
|
|
38
|
-
//
|
|
39
|
-
|
|
38
|
+
// belongsTo section as <select>s — skip them in the main loop so we
|
|
39
|
+
// don't render both a plain-text input and a dropdown for the same
|
|
40
|
+
// column.
|
|
41
|
+
const shadowedFKs = new Set(belongsTo.map(rel => `${rel.name}Id`));
|
|
40
42
|
const fields = selectFormFields(context.model, shadowedFKs);
|
|
41
43
|
|
|
42
44
|
const lines: string[] = [];
|
|
@@ -52,24 +54,43 @@ export function composeFormBody(context: EmitContext): string {
|
|
|
52
54
|
lines.push(...renderInput(field));
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
lines.push(
|
|
57
|
-
lines.push(' {/* TODO: swap these text inputs for <select>s that load the related');
|
|
58
|
-
lines.push(' entities. See useEntitiesQuery(TargetModel) in the runtime hooks. */}');
|
|
59
|
-
for (const rel of belongsTo) {
|
|
60
|
-
lines.push(...renderInput({
|
|
61
|
-
name: `${rel}Id`,
|
|
62
|
-
label: humanize(rel),
|
|
63
|
-
type: 'String',
|
|
64
|
-
required: true,
|
|
65
|
-
}));
|
|
66
|
-
}
|
|
57
|
+
for (const rel of belongsTo) {
|
|
58
|
+
lines.push(...renderRelationshipSelect(rel));
|
|
67
59
|
}
|
|
68
60
|
|
|
69
61
|
lines.push('</div>');
|
|
70
62
|
return lines.join('\n');
|
|
71
63
|
}
|
|
72
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Emit a <select> dropdown for a belongsTo relationship. The options
|
|
67
|
+
* array (`${relName}Options`) is populated by the hook call wired in
|
|
68
|
+
* via view-emitter's RELATED_HOOKS substitution. Labels use
|
|
69
|
+
* `getEntityDisplayName` so users see human names instead of UUIDs.
|
|
70
|
+
*/
|
|
71
|
+
function renderRelationshipSelect(rel: BelongsToRel): string[] {
|
|
72
|
+
const fkName = `${rel.name}Id`;
|
|
73
|
+
const varName = `${rel.name}Options`;
|
|
74
|
+
const label = humanize(rel.name);
|
|
75
|
+
|
|
76
|
+
return [
|
|
77
|
+
' <div>',
|
|
78
|
+
` <label className="${LABEL_CLS}" htmlFor="${fkName}">${label} *</label>`,
|
|
79
|
+
` <select`,
|
|
80
|
+
` id="${fkName}"`,
|
|
81
|
+
` className="${INPUT_CLS}"`,
|
|
82
|
+
` value={String((formData as any).${fkName} ?? '')}`,
|
|
83
|
+
` onChange={e => handleChange('${fkName}', e.target.value)} required`,
|
|
84
|
+
` >`,
|
|
85
|
+
` <option value="">— choose —</option>`,
|
|
86
|
+
` {${varName}.map((opt: any) => (`,
|
|
87
|
+
` <option key={opt.id} value={opt.id}>{getEntityDisplayName(opt)}</option>`,
|
|
88
|
+
` ))}`,
|
|
89
|
+
` </select>`,
|
|
90
|
+
' </div>',
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
73
94
|
// ──────────────────────────────────────────────────────────────────────
|
|
74
95
|
// Field selection
|
|
75
96
|
// ──────────────────────────────────────────────────────────────────────
|
|
@@ -90,6 +111,8 @@ function selectFormFields(
|
|
|
90
111
|
): FieldInfo[] {
|
|
91
112
|
const out: FieldInfo[] = [];
|
|
92
113
|
const attrs = model.attributes ?? {};
|
|
114
|
+
const lifecycleStates = extractLifecycleStates(model);
|
|
115
|
+
|
|
93
116
|
for (const [name, rawDef] of Object.entries(attrs)) {
|
|
94
117
|
if (AUTO_GENERATED_FIELD_NAMES.has(name)) continue;
|
|
95
118
|
if (skip.has(name)) continue;
|
|
@@ -102,7 +125,14 @@ function selectFormFields(
|
|
|
102
125
|
// they're present.
|
|
103
126
|
const type = (def?.type ?? parseTypeFromConvention(def)) ?? 'String';
|
|
104
127
|
const required = def?.required === true || hasConventionFlag(def, 'required');
|
|
105
|
-
const
|
|
128
|
+
const declaredValues = def?.values as string[] | undefined;
|
|
129
|
+
// Lifecycle-backed attributes: if the model declares a lifecycle
|
|
130
|
+
// with the same name as this attribute, the attribute's valid
|
|
131
|
+
// values are the lifecycle states. Matches the FK rule's spirit —
|
|
132
|
+
// any attribute whose legal values are declared elsewhere in the
|
|
133
|
+
// spec should render as a <select>, not free text.
|
|
134
|
+
const lifecycleValues = lifecycleStates.get(name);
|
|
135
|
+
const values = declaredValues ?? lifecycleValues;
|
|
106
136
|
const maxLength = def?.maxLength ?? def?.max as number | undefined;
|
|
107
137
|
const isLongString =
|
|
108
138
|
typeof maxLength === 'number' && maxLength > 100;
|
|
@@ -119,6 +149,58 @@ function selectFormFields(
|
|
|
119
149
|
return out;
|
|
120
150
|
}
|
|
121
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Map from attribute name → list of lifecycle states, extracted from
|
|
154
|
+
* the model's `lifecycles` section. Supports both the convention-string
|
|
155
|
+
* shape (`flow: "a -> b -> c"`) and structured (`states: ["a", ...]`).
|
|
156
|
+
*
|
|
157
|
+
* Example:
|
|
158
|
+
* lifecycles:
|
|
159
|
+
* status:
|
|
160
|
+
* flow: "planning -> active -> completed -> archived"
|
|
161
|
+
* returns { status → ['planning', 'active', 'completed', 'archived'] }
|
|
162
|
+
*/
|
|
163
|
+
function extractLifecycleStates(model: ModelSpec): Map<string, string[]> {
|
|
164
|
+
const out = new Map<string, string[]>();
|
|
165
|
+
const lifecycles = (model as ModelSpec & { lifecycles?: Record<string, unknown> }).lifecycles ?? {};
|
|
166
|
+
|
|
167
|
+
for (const [name, rawDef] of Object.entries(lifecycles)) {
|
|
168
|
+
if (!rawDef || typeof rawDef !== 'object') continue;
|
|
169
|
+
// The parser normalizes lifecycles into a rich structured form:
|
|
170
|
+
// { flow: "a -> b -> c",
|
|
171
|
+
// states: [{ name: "a" }, { name: "b" }, { name: "c" }],
|
|
172
|
+
// transitions: [...],
|
|
173
|
+
// initialState: "a" }
|
|
174
|
+
// Also support the pre-normalization shape (states as plain strings,
|
|
175
|
+
// or just a flow string) so the composer works on raw specs too.
|
|
176
|
+
const def = rawDef as {
|
|
177
|
+
flow?: string;
|
|
178
|
+
states?: Array<string | { name?: string; id?: string }>;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (Array.isArray(def.states) && def.states.length > 0) {
|
|
182
|
+
const names = def.states
|
|
183
|
+
.map(s => (typeof s === 'string' ? s : s?.name ?? s?.id ?? ''))
|
|
184
|
+
.filter(Boolean);
|
|
185
|
+
if (names.length > 0) {
|
|
186
|
+
out.set(name, names);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (typeof def.flow === 'string') {
|
|
192
|
+
// Split on -> with optional whitespace. Also accept commas as a
|
|
193
|
+
// fallback separator so a state list like "a, b, c" still works.
|
|
194
|
+
const states = def.flow
|
|
195
|
+
.split(/\s*(?:->|,)\s*/)
|
|
196
|
+
.map(s => s.trim())
|
|
197
|
+
.filter(Boolean);
|
|
198
|
+
if (states.length > 0) out.set(name, states);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
122
204
|
/** Structured attribute object from the parsed spec. */
|
|
123
205
|
interface AttributeShape {
|
|
124
206
|
type?: string;
|
|
@@ -152,14 +234,9 @@ function normalizeType(type: string): string {
|
|
|
152
234
|
return type.replace(/[^A-Za-z].*$/, '');
|
|
153
235
|
}
|
|
154
236
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
for (const [name, def] of Object.entries(rels)) {
|
|
159
|
-
if ((def as { type?: string })?.type === 'belongsTo') out.push(name);
|
|
160
|
-
}
|
|
161
|
-
return out;
|
|
162
|
-
}
|
|
237
|
+
// (`extractBelongsTo` was removed — the shared helper in
|
|
238
|
+
// `./belongs-to.ts` returns both the relationship name AND the target
|
|
239
|
+
// model so view-emitter can wire the hook call for each dropdown.)
|
|
163
240
|
|
|
164
241
|
// ──────────────────────────────────────────────────────────────────────
|
|
165
242
|
// Input rendering
|
|
@@ -46,14 +46,20 @@ export function getEntityDisplayName(entity: Record<string, unknown> | null | un
|
|
|
46
46
|
/**
|
|
47
47
|
* Map an entity id to the display name of the record with that id in
|
|
48
48
|
* a provided list. Handy for resolving belongsTo FK columns in
|
|
49
|
-
* list / detail views.
|
|
49
|
+
* list / detail / dashboard views.
|
|
50
|
+
*
|
|
51
|
+
* Typed as \`readonly unknown[]\` so callers can pass arrays of
|
|
52
|
+
* specific model types (e.g. \`User[]\`) without needing to cast; the
|
|
53
|
+
* runtime check on \`.id\` narrows safely.
|
|
50
54
|
*/
|
|
51
55
|
export function resolveEntityDisplayName(
|
|
52
56
|
id: unknown,
|
|
53
|
-
records:
|
|
57
|
+
records: readonly unknown[]
|
|
54
58
|
): string {
|
|
55
59
|
if (id == null) return '';
|
|
56
|
-
const match = records.find(r
|
|
60
|
+
const match = records.find((r): r is Record<string, unknown> =>
|
|
61
|
+
typeof r === 'object' && r !== null && (r as { id?: unknown }).id === id
|
|
62
|
+
);
|
|
57
63
|
return match ? getEntityDisplayName(match) : String(id);
|
|
58
64
|
}
|
|
59
65
|
`;
|