@specverse/engines 4.2.0 → 4.2.2

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.
Files changed (87) hide show
  1. package/assets/templates/default/specs/main.specly +65 -0
  2. package/dist/libs/instance-factories/CURVED-INTERFACE.md +278 -0
  3. package/dist/libs/instance-factories/README.md +73 -0
  4. package/dist/libs/instance-factories/applications/README.md +51 -0
  5. package/dist/libs/instance-factories/applications/generic-app.yaml +52 -0
  6. package/dist/libs/instance-factories/applications/react-app-runtime.yaml +139 -0
  7. package/dist/libs/instance-factories/applications/react-app-starter.yaml +143 -0
  8. package/dist/libs/instance-factories/applications/templates/react/env-example-generator.js +24 -2
  9. package/dist/libs/instance-factories/applications/templates/react/vite-config-generator.js +54 -33
  10. package/dist/libs/instance-factories/applications/templates/react-starter/README.md +211 -0
  11. package/dist/libs/instance-factories/applications/templates/react-starter/api-types-starter-generator.js +69 -0
  12. package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +1 -1
  13. package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +40 -0
  14. package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +11 -3
  15. package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +18 -16
  16. package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +50 -23
  17. package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +9 -3
  18. package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +17 -7
  19. package/dist/libs/instance-factories/applications/templates/react-starter/orchestrator.js +16 -5
  20. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/dashboard.tsx.template +49 -0
  21. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +96 -0
  22. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +116 -0
  23. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +74 -0
  24. package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +95 -0
  25. package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +26 -1
  26. package/dist/libs/instance-factories/archived/fastify-prisma.yaml +104 -0
  27. package/dist/libs/instance-factories/cli/README.md +43 -0
  28. package/dist/libs/instance-factories/cli/commander-js.yaml +55 -0
  29. package/dist/libs/instance-factories/cli/templates/commander/command-generator.js +49 -1
  30. package/dist/libs/instance-factories/communication/README.md +47 -0
  31. package/dist/libs/instance-factories/communication/event-emitter.yaml +60 -0
  32. package/dist/libs/instance-factories/communication/rabbitmq-events.yaml +87 -0
  33. package/dist/libs/instance-factories/controllers/README.md +42 -0
  34. package/dist/libs/instance-factories/controllers/fastify.yaml +139 -0
  35. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +29 -2
  36. package/dist/libs/instance-factories/infrastructure/README.md +29 -0
  37. package/dist/libs/instance-factories/infrastructure/docker-k8s.yaml +61 -0
  38. package/dist/libs/instance-factories/orms/README.md +54 -0
  39. package/dist/libs/instance-factories/orms/prisma.yaml +89 -0
  40. package/dist/libs/instance-factories/orms/templates/prisma/schema-generator.js +2 -2
  41. package/dist/libs/instance-factories/scaffolding/README.md +49 -0
  42. package/dist/libs/instance-factories/scaffolding/generic-scaffold.yaml +65 -0
  43. package/dist/libs/instance-factories/sdks/README.md +28 -0
  44. package/dist/libs/instance-factories/sdks/python-sdk.yaml +66 -0
  45. package/dist/libs/instance-factories/sdks/typescript-sdk.yaml +59 -0
  46. package/dist/libs/instance-factories/services/README.md +55 -0
  47. package/dist/libs/instance-factories/services/prisma-services.yaml +71 -0
  48. package/dist/libs/instance-factories/storage/README.md +34 -0
  49. package/dist/libs/instance-factories/storage/mongodb.yaml +79 -0
  50. package/dist/libs/instance-factories/storage/postgresql.yaml +75 -0
  51. package/dist/libs/instance-factories/storage/redis.yaml +79 -0
  52. package/dist/libs/instance-factories/testing/README.md +40 -0
  53. package/dist/libs/instance-factories/testing/vitest-tests.yaml +63 -0
  54. package/dist/libs/instance-factories/tools/README.md +70 -0
  55. package/dist/libs/instance-factories/tools/mcp.yaml +36 -0
  56. package/dist/libs/instance-factories/tools/vscode.yaml +35 -0
  57. package/dist/libs/instance-factories/validation/README.md +38 -0
  58. package/dist/libs/instance-factories/validation/zod.yaml +56 -0
  59. package/dist/realize/engines/code-generator.d.ts.map +1 -1
  60. package/dist/realize/engines/code-generator.js +3 -0
  61. package/dist/realize/engines/code-generator.js.map +1 -1
  62. package/libs/instance-factories/applications/react-app-starter.yaml +10 -17
  63. package/libs/instance-factories/applications/templates/react/env-example-generator.ts +24 -2
  64. package/libs/instance-factories/applications/templates/react/vite-config-generator.ts +54 -33
  65. package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +5 -4
  66. package/libs/instance-factories/applications/templates/react-starter/__tests__/form-body-composer.test.ts +18 -5
  67. package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +83 -62
  68. package/libs/instance-factories/applications/templates/react-starter/api-types-starter-generator.ts +98 -0
  69. package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +1 -1
  70. package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +82 -0
  71. package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +20 -5
  72. package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +33 -33
  73. package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +107 -30
  74. package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +9 -3
  75. package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +34 -8
  76. package/libs/instance-factories/applications/templates/react-starter/orchestrator.ts +41 -26
  77. package/libs/instance-factories/applications/templates/react-starter/skeletons/dashboard.tsx.template +2 -0
  78. package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +2 -0
  79. package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +2 -0
  80. package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +2 -0
  81. package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +124 -0
  82. package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +58 -0
  83. package/libs/instance-factories/cli/templates/commander/command-generator.ts +49 -1
  84. package/libs/instance-factories/controllers/fastify.yaml +7 -0
  85. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +36 -2
  86. package/libs/instance-factories/orms/templates/prisma/schema-generator.ts +11 -4
  87. package/package.json +2 -1
@@ -1,8 +1,10 @@
1
1
  import { METADATA_FIELDS } from "@specverse/runtime/views/core";
2
+ import { extractBelongsToTargets } from "./belongs-to.js";
2
3
  const METADATA_FIELD_NAMES = new Set(METADATA_FIELDS);
3
4
  function composeDetailBody(context) {
4
- const { business, metadata } = partitionFields(context.model);
5
- const belongsTo = extractBelongsTo(context.model);
5
+ const belongsTo = extractBelongsToTargets(context.model);
6
+ const fkExcludes = new Set(belongsTo.map((r) => `${r.name}Id`));
7
+ const { business, metadata } = partitionFields(context.model, fkExcludes);
6
8
  const lines = [];
7
9
  lines.push('<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">');
8
10
  if (business.length > 0) {
@@ -11,17 +13,14 @@ function composeDetailBody(context) {
11
13
  lines.push(...renderField(field, { muted: false }));
12
14
  }
13
15
  lines.push(" </dl>");
14
- } else {
16
+ } else if (belongsTo.length === 0) {
15
17
  lines.push(' <p className="text-sm text-gray-400">No business fields defined for this model.</p>');
16
18
  }
17
19
  if (belongsTo.length > 0) {
18
20
  lines.push("");
19
- lines.push(" {/* TODO: resolve FK ids \u2192 related entity display names.");
20
- lines.push(" See @specverse/runtime/views/core/entity-display for the");
21
- lines.push(" canonical resolver, or load the related record in a hook. */}");
22
21
  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">');
23
22
  for (const rel of belongsTo) {
24
- lines.push(...renderField({ name: `${rel}Id`, label: humanize(rel) }, { muted: false }));
23
+ lines.push(...renderRelationshipField(rel));
25
24
  }
26
25
  lines.push(" </dl>");
27
26
  }
@@ -36,11 +35,12 @@ function composeDetailBody(context) {
36
35
  lines.push("</div>");
37
36
  return lines.join("\n");
38
37
  }
39
- function partitionFields(model) {
38
+ function partitionFields(model, exclude = /* @__PURE__ */ new Set()) {
40
39
  const attrs = Object.keys(model.attributes ?? {});
41
40
  const business = [];
42
41
  const metadata = [];
43
42
  for (const name of attrs) {
43
+ if (exclude.has(name)) continue;
44
44
  const field = { name, label: humanize(name) };
45
45
  if (METADATA_FIELD_NAMES.has(name)) {
46
46
  metadata.push(field);
@@ -50,14 +50,16 @@ function partitionFields(model) {
50
50
  }
51
51
  return { business, metadata };
52
52
  }
53
- function extractBelongsTo(model) {
54
- const rels = model.relationships ?? {};
55
- const out = [];
56
- for (const [name, def] of Object.entries(rels)) {
57
- const d = def;
58
- if (d?.type === "belongsTo") out.push(name);
59
- }
60
- return out;
53
+ function renderRelationshipField(rel) {
54
+ const label = humanize(rel.name);
55
+ const fkName = `${rel.name}Id`;
56
+ const optionsVar = `${rel.name}Options`;
57
+ return [
58
+ " <div>",
59
+ ` <dt className="text-sm font-medium text-gray-500 dark:text-gray-400">${label}</dt>`,
60
+ ` <dd className="mt-1 text-sm text-gray-900 dark:text-gray-100 break-words">{resolveEntityDisplayName((item as any).${fkName}, ${optionsVar})}</dd>`,
61
+ " </div>"
62
+ ];
61
63
  }
62
64
  function renderField(field, opts) {
63
65
  const label = field.label ?? humanize(field.name);
@@ -1,8 +1,9 @@
1
1
  import { METADATA_FIELDS } from "@specverse/runtime/views/core";
2
+ import { extractBelongsToTargets } from "./belongs-to.js";
2
3
  const AUTO_GENERATED_FIELD_NAMES = new Set(METADATA_FIELDS);
3
4
  function composeFormBody(context) {
4
- const belongsTo = extractBelongsTo(context.model);
5
- const shadowedFKs = new Set(belongsTo.map((rel) => `${rel}Id`));
5
+ const belongsTo = extractBelongsToTargets(context.model);
6
+ const shadowedFKs = new Set(belongsTo.map((rel) => `${rel.name}Id`));
6
7
  const fields = selectFormFields(context.model, shadowedFKs);
7
8
  const lines = [];
8
9
  lines.push('<div className="space-y-4">');
@@ -14,25 +15,37 @@ function composeFormBody(context) {
14
15
  for (const field of fields) {
15
16
  lines.push(...renderInput(field));
16
17
  }
17
- if (belongsTo.length > 0) {
18
- lines.push("");
19
- lines.push(" {/* TODO: swap these text inputs for <select>s that load the related");
20
- lines.push(" entities. See useEntitiesQuery(TargetModel) in the runtime hooks. */}");
21
- for (const rel of belongsTo) {
22
- lines.push(...renderInput({
23
- name: `${rel}Id`,
24
- label: humanize(rel),
25
- type: "String",
26
- required: true
27
- }));
28
- }
18
+ for (const rel of belongsTo) {
19
+ lines.push(...renderRelationshipSelect(rel));
29
20
  }
30
21
  lines.push("</div>");
31
22
  return lines.join("\n");
32
23
  }
24
+ function renderRelationshipSelect(rel) {
25
+ const fkName = `${rel.name}Id`;
26
+ const varName = `${rel.name}Options`;
27
+ const label = humanize(rel.name);
28
+ return [
29
+ " <div>",
30
+ ` <label className="${LABEL_CLS}" htmlFor="${fkName}">${label} *</label>`,
31
+ ` <select`,
32
+ ` id="${fkName}"`,
33
+ ` className="${INPUT_CLS}"`,
34
+ ` value={String((formData as any).${fkName} ?? '')}`,
35
+ ` onChange={e => handleChange('${fkName}', e.target.value)} required`,
36
+ ` >`,
37
+ ` <option value="">\u2014 choose \u2014</option>`,
38
+ ` {${varName}.map((opt: any) => (`,
39
+ ` <option key={opt.id} value={opt.id}>{getEntityDisplayName(opt)}</option>`,
40
+ ` ))}`,
41
+ ` </select>`,
42
+ " </div>"
43
+ ];
44
+ }
33
45
  function selectFormFields(model, skip = /* @__PURE__ */ new Set()) {
34
46
  const out = [];
35
47
  const attrs = model.attributes ?? {};
48
+ const lifecycleStates = extractLifecycleStates(model);
36
49
  for (const [name, rawDef] of Object.entries(attrs)) {
37
50
  if (AUTO_GENERATED_FIELD_NAMES.has(name)) continue;
38
51
  if (skip.has(name)) continue;
@@ -40,7 +53,9 @@ function selectFormFields(model, skip = /* @__PURE__ */ new Set()) {
40
53
  if (def?.auto) continue;
41
54
  const type = def?.type ?? parseTypeFromConvention(def) ?? "String";
42
55
  const required = def?.required === true || hasConventionFlag(def, "required");
43
- const values = def?.values;
56
+ const declaredValues = def?.values;
57
+ const lifecycleValues = lifecycleStates.get(name);
58
+ const values = declaredValues ?? lifecycleValues;
44
59
  const maxLength = def?.maxLength ?? def?.max;
45
60
  const isLongString = typeof maxLength === "number" && maxLength > 100;
46
61
  out.push({
@@ -54,6 +69,26 @@ function selectFormFields(model, skip = /* @__PURE__ */ new Set()) {
54
69
  }
55
70
  return out;
56
71
  }
72
+ function extractLifecycleStates(model) {
73
+ const out = /* @__PURE__ */ new Map();
74
+ const lifecycles = model.lifecycles ?? {};
75
+ for (const [name, rawDef] of Object.entries(lifecycles)) {
76
+ if (!rawDef || typeof rawDef !== "object") continue;
77
+ const def = rawDef;
78
+ if (Array.isArray(def.states) && def.states.length > 0) {
79
+ const names = def.states.map((s) => typeof s === "string" ? s : s?.name ?? s?.id ?? "").filter(Boolean);
80
+ if (names.length > 0) {
81
+ out.set(name, names);
82
+ continue;
83
+ }
84
+ }
85
+ if (typeof def.flow === "string") {
86
+ const states = def.flow.split(/\s*(?:->|,)\s*/).map((s) => s.trim()).filter(Boolean);
87
+ if (states.length > 0) out.set(name, states);
88
+ }
89
+ }
90
+ return out;
91
+ }
57
92
  function parseTypeFromConvention(def) {
58
93
  if (typeof def === "string") {
59
94
  const first = def.trim().split(/\s+/)[0];
@@ -70,14 +105,6 @@ function hasConventionFlag(def, flag) {
70
105
  function normalizeType(type) {
71
106
  return type.replace(/[^A-Za-z].*$/, "");
72
107
  }
73
- function extractBelongsTo(model) {
74
- const rels = model.relationships ?? {};
75
- const out = [];
76
- for (const [name, def] of Object.entries(rels)) {
77
- if (def?.type === "belongsTo") out.push(name);
78
- }
79
- return out;
80
- }
81
108
  const INPUT_CLS = "w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100";
82
109
  const LABEL_CLS = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
83
110
  function renderInput(field) {
@@ -28,14 +28,20 @@ export function getEntityDisplayName(entity: Record<string, unknown> | null | un
28
28
  /**
29
29
  * Map an entity id to the display name of the record with that id in
30
30
  * a provided list. Handy for resolving belongsTo FK columns in
31
- * list / detail views.
31
+ * list / detail / dashboard views.
32
+ *
33
+ * Typed as \`readonly unknown[]\` so callers can pass arrays of
34
+ * specific model types (e.g. \`User[]\`) without needing to cast; the
35
+ * runtime check on \`.id\` narrows safely.
32
36
  */
33
37
  export function resolveEntityDisplayName(
34
38
  id: unknown,
35
- records: ReadonlyArray<Record<string, unknown>>
39
+ records: readonly unknown[]
36
40
  ): string {
37
41
  if (id == null) return '';
38
- const match = records.find(r => r.id === id);
42
+ const match = records.find((r): r is Record<string, unknown> =>
43
+ typeof r === 'object' && r !== null && (r as { id?: unknown }).id === id
44
+ );
39
45
  return match ? getEntityDisplayName(match) : String(id);
40
46
  }
41
47
  `;
@@ -1,17 +1,25 @@
1
1
  import { createUniversalTailwindAdapter } from "@specverse/runtime/views/tailwind";
2
2
  import { inferFieldsFromSchema } from "@specverse/runtime/views/core";
3
3
  import { htmlToJsx } from "./html-to-jsx.js";
4
+ import { buildFKMap } from "./belongs-to.js";
4
5
  const TBODY_SENTINEL = "__SPECVERSE_TBODY_ROWS__";
5
6
  function composeListBody(context) {
6
- const columns = inferColumns(context);
7
- const headers = columns.map(humanize);
7
+ const fkMap = buildFKMap(context.model);
8
+ const inferred = inferColumns(context);
9
+ const inferredSet = new Set(inferred);
10
+ const extraFKs = [...fkMap.keys()].filter((k) => !inferredSet.has(k));
11
+ const columns = [...inferred, ...extraFKs];
12
+ const headers = columns.map((col) => {
13
+ const fk = fkMap.get(col);
14
+ return humanize(fk ? fk.name : col);
15
+ });
8
16
  const adapter = createUniversalTailwindAdapter({ darkMode: true });
9
17
  const shellHtml = adapter.components.table.render({
10
18
  properties: { columns: headers },
11
19
  children: TBODY_SENTINEL
12
20
  });
13
21
  const shellJsx = htmlToJsx(shellHtml);
14
- const rowMap = buildRowMap(columns);
22
+ const rowMap = buildRowMap(columns, fkMap);
15
23
  if (!shellJsx.includes(TBODY_SENTINEL)) {
16
24
  throw new Error(
17
25
  "composeListBody: tbody sentinel not present in adapter output. The canonical Tailwind adapter may have changed its table rendering."
@@ -25,10 +33,12 @@ function inferColumns(context) {
25
33
  function humanize(name) {
26
34
  return name.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase()).trim();
27
35
  }
28
- function buildRowMap(columns) {
29
- const cells = columns.map(
30
- (col) => ` <td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">{String((item as any).${col} ?? '')}</td>`
31
- ).join("\n");
36
+ function buildRowMap(columns, fkMap) {
37
+ const cells = columns.map((col) => {
38
+ const fk = fkMap.get(col);
39
+ const expr = fk ? `{resolveEntityDisplayName((item as any).${col}, ${fk.name}Options)}` : `{String((item as any).${col} ?? '')}`;
40
+ return ` <td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">${expr}</td>`;
41
+ }).join("\n");
32
42
  return [
33
43
  "{filtered.map((item, idx) => (",
34
44
  " <tr",
@@ -1,3 +1,5 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
2
+ import { join, dirname } from "path";
1
3
  import { generate as generateViews } from "./views-generator.js";
2
4
  import { generate as generateAppTsx } from "./app-tsx-generator.js";
3
5
  import { generate as generatePackageJson } from "./package-json-generator.js";
@@ -9,7 +11,9 @@ import {
9
11
  HASHES_FILE
10
12
  } from "./regen-safety.js";
11
13
  async function generate(context) {
12
- const { spec, projectRoot } = context;
14
+ const { spec, outputDir } = context;
15
+ const frontendDir = context.frontendDir || "frontend";
16
+ const projectRoot = frontendDir === "." ? outputDir : join(outputDir, frontendDir);
13
17
  const proposed = {};
14
18
  const viewFiles = await generateViews({ spec });
15
19
  Object.assign(proposed, viewFiles);
@@ -18,13 +22,20 @@ async function generate(context) {
18
22
  const prevManifest = loadHashManifest(projectRoot);
19
23
  const result = reconcileWrites(projectRoot, proposed, prevManifest);
20
24
  console.log(summarize(result, projectRoot));
21
- const manifestPath = `${HASHES_DIR}/${HASHES_FILE}`;
22
- const out = {
25
+ const toWrite = {
23
26
  ...result.approvedWrites,
24
- [manifestPath]: JSON.stringify(result.manifest, null, 2) + "\n"
27
+ [`${HASHES_DIR}/${HASHES_FILE}`]: JSON.stringify(result.manifest, null, 2) + "\n"
25
28
  };
26
- return out;
29
+ for (const [rel, content] of Object.entries(toWrite)) {
30
+ const abs = join(projectRoot, rel);
31
+ const dir = dirname(abs);
32
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
33
+ writeFileSync(abs, content, "utf-8");
34
+ }
35
+ return "";
27
36
  }
37
+ var stdin_default = generate;
28
38
  export {
39
+ stdin_default as default,
29
40
  generate
30
41
  };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * {{MODEL_NAME}}DashboardView — generated by @specverse/realize (ReactAppStarter)
3
+ *
4
+ * Safe to edit. Edits are preserved across regeneration via content
5
+ * hashing (see .specverse-gen/hashes.json). To accept an upstream
6
+ * regeneration of this file, delete it first, then run `spv realize`.
7
+ *
8
+ * A minimal dashboard: summary counts derived from the list query,
9
+ * plus a compact preview of recent records. Charts and aggregation
10
+ * metrics are deferred — add them as the backend grows suitable
11
+ * endpoints.
12
+ */
13
+ import { useMemo } from 'react';
14
+ import { use{{PLURAL_MODEL}}Query } from '../hooks/useApi';
15
+ {{RELATED_IMPORTS}}
16
+ import type { {{MODEL_NAME}} } from '../types/api';
17
+
18
+ interface {{MODEL_NAME}}DashboardViewProps {
19
+ /** Number of recent records to preview. Default 5. */
20
+ previewLimit?: number;
21
+ onSelect?: (item: {{MODEL_NAME}}) => void;
22
+ }
23
+
24
+ export function {{MODEL_NAME}}DashboardView({
25
+ previewLimit = 5,
26
+ onSelect,
27
+ }: {{MODEL_NAME}}DashboardViewProps) {
28
+ const { data: items = [], isLoading, error } = use{{PLURAL_MODEL}}Query();
29
+ {{RELATED_HOOKS}}
30
+
31
+ const preview = useMemo(
32
+ () => items.slice(0, previewLimit),
33
+ [items, previewLimit]
34
+ );
35
+
36
+ if (isLoading) return <div className="p-4 text-gray-500">Loading dashboard…</div>;
37
+ if (error) return <div className="p-4 text-red-600">Error loading {{PLURAL_LOWER}}: {String(error)}</div>;
38
+
39
+ return (
40
+ <div className="p-6 space-y-6">
41
+ <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
42
+ {{MODEL_NAME}} dashboard
43
+ </h2>
44
+
45
+ {/* Pattern-rendered dashboard body (metrics + preview). Edit freely. */}
46
+ {{BODY}}
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * {{MODEL_NAME}}DetailView — generated by @specverse/realize (ReactAppStarter)
3
+ *
4
+ * Safe to edit. Edits are preserved across regeneration via content
5
+ * hashing (see .specverse-gen/hashes.json). To accept an upstream
6
+ * regeneration of this file, delete it first, then run `spv realize`.
7
+ */
8
+ import { use{{PLURAL_MODEL}}Query, useDelete{{MODEL_NAME}}Mutation } from '../hooks/useApi';
9
+ {{RELATED_IMPORTS}}
10
+ import type { {{MODEL_NAME}} } from '../types/api';
11
+
12
+ interface {{MODEL_NAME}}DetailViewProps {
13
+ entityId: string | number;
14
+ onEdit?: (item: {{MODEL_NAME}}) => void;
15
+ onBack?: () => void;
16
+ /** Called after the delete mutation succeeds. */
17
+ onDeleted?: () => void;
18
+ }
19
+
20
+ export function {{MODEL_NAME}}DetailView({
21
+ entityId,
22
+ onEdit,
23
+ onBack,
24
+ onDeleted,
25
+ }: {{MODEL_NAME}}DetailViewProps) {
26
+ const { data: items = [], isLoading, error } = use{{PLURAL_MODEL}}Query();
27
+ const deleteItem = useDelete{{MODEL_NAME}}Mutation();
28
+ {{RELATED_HOOKS}}
29
+
30
+ const item = items.find(
31
+ (x: {{MODEL_NAME}}) => (x as any).id === entityId
32
+ );
33
+
34
+ if (isLoading) return <div className="p-4 text-gray-500">Loading…</div>;
35
+ 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
+ return (
49
+ <div className="p-6 space-y-4">
50
+ <div className="flex items-center justify-between">
51
+ <div className="flex items-center gap-3">
52
+ {onBack && (
53
+ <button
54
+ type="button"
55
+ onClick={onBack}
56
+ className="text-sm text-gray-500 hover:text-gray-700"
57
+ >
58
+ ← Back
59
+ </button>
60
+ )}
61
+ <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
62
+ {{MODEL_NAME}} detail
63
+ </h2>
64
+ </div>
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>
74
+ )}
75
+ <button
76
+ type="button"
77
+ onClick={handleDelete}
78
+ disabled={deleteItem.isPending}
79
+ className="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60"
80
+ >
81
+ {deleteItem.isPending ? 'Deleting…' : 'Delete'}
82
+ </button>
83
+ </div>
84
+ </div>
85
+
86
+ {/* Pattern-rendered detail body (fields). Edit freely. */}
87
+ {{BODY}}
88
+
89
+ {deleteItem.isError && (
90
+ <div className="p-2 text-sm text-red-600">
91
+ Delete failed: {String(deleteItem.error)}
92
+ </div>
93
+ )}
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * {{MODEL_NAME}}FormView — generated by @specverse/realize (ReactAppStarter)
3
+ *
4
+ * Safe to edit. Edits are preserved across regeneration via content
5
+ * hashing (see .specverse-gen/hashes.json). To accept an upstream
6
+ * regeneration of this file, delete it first, then run `spv realize`.
7
+ *
8
+ * Controlled form. One field per non-auto-generated attribute.
9
+ * Mode: 'create' (default) or 'update' — passed as a prop.
10
+ */
11
+ import { useEffect, useState } from 'react';
12
+ import {
13
+ use{{PLURAL_MODEL}}Query,
14
+ useCreate{{MODEL_NAME}}Mutation,
15
+ useUpdate{{MODEL_NAME}}Mutation,
16
+ } from '../hooks/useApi';
17
+ {{RELATED_IMPORTS}}
18
+ import type { {{MODEL_NAME}} } from '../types/api';
19
+
20
+ type FormMode = 'create' | 'update';
21
+
22
+ interface {{MODEL_NAME}}FormViewProps {
23
+ mode?: FormMode;
24
+ /** Required in update mode. */
25
+ entityId?: string | number;
26
+ onSuccess?: (item: {{MODEL_NAME}}) => void;
27
+ onCancel?: () => void;
28
+ }
29
+
30
+ export function {{MODEL_NAME}}FormView({
31
+ mode = 'create',
32
+ entityId,
33
+ onSuccess,
34
+ onCancel,
35
+ }: {{MODEL_NAME}}FormViewProps) {
36
+ const { data: items = [] } = use{{PLURAL_MODEL}}Query();
37
+ const createItem = useCreate{{MODEL_NAME}}Mutation();
38
+ const updateItem = useUpdate{{MODEL_NAME}}Mutation();
39
+ {{RELATED_HOOKS}}
40
+
41
+ const existing =
42
+ mode === 'update'
43
+ ? items.find((x: {{MODEL_NAME}}) => (x as any).id === entityId)
44
+ : undefined;
45
+
46
+ const [formData, setFormData] = useState<Partial<{{MODEL_NAME}}>>(existing ?? {});
47
+
48
+ // When the fetched list lands after initial render (update mode),
49
+ // hydrate the form with the loaded entity's data.
50
+ useEffect(() => {
51
+ if (existing) setFormData(existing);
52
+ }, [existing]);
53
+
54
+ const handleChange = (field: string, value: unknown) => {
55
+ setFormData(prev => ({ ...prev, [field]: value }) as Partial<{{MODEL_NAME}}>);
56
+ };
57
+
58
+ const handleSubmit = async (event: React.FormEvent) => {
59
+ event.preventDefault();
60
+ try {
61
+ const result =
62
+ mode === 'create'
63
+ ? await createItem.mutateAsync(formData as {{MODEL_NAME}})
64
+ : await updateItem.mutateAsync({
65
+ id: entityId as any,
66
+ data: formData as Partial<{{MODEL_NAME}}>,
67
+ });
68
+ onSuccess?.(result as {{MODEL_NAME}});
69
+ } catch {
70
+ // Mutation errors are surfaced below via createItem / updateItem.
71
+ }
72
+ };
73
+
74
+ const mutation = mode === 'create' ? createItem : updateItem;
75
+ const submitLabel =
76
+ mode === 'create'
77
+ ? mutation.isPending ? 'Creating…' : 'Create {{MODEL_NAME}}'
78
+ : mutation.isPending ? 'Updating…' : 'Update {{MODEL_NAME}}';
79
+
80
+ return (
81
+ <form onSubmit={handleSubmit} className="p-6 space-y-6">
82
+ <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
83
+ {mode === 'create' ? 'New {{MODEL_NAME}}' : 'Edit {{MODEL_NAME}}'}
84
+ </h2>
85
+
86
+ {/* Pattern-rendered form fields. Edit freely. */}
87
+ {{BODY}}
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 && (
98
+ <button
99
+ type="button"
100
+ onClick={onCancel}
101
+ className="rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
102
+ >
103
+ Cancel
104
+ </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
+ </div>
113
+ )}
114
+ </form>
115
+ );
116
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * {{MODEL_NAME}}ListView — generated by @specverse/realize (ReactAppStarter)
3
+ *
4
+ * Safe to edit. Edits are preserved across regeneration via content
5
+ * hashing (see .specverse-gen/hashes.json). To accept an upstream
6
+ * regeneration of this file, delete it first, then run `spv realize`.
7
+ */
8
+ import { useState, useMemo } from 'react';
9
+ import { use{{PLURAL_MODEL}}Query, useDelete{{MODEL_NAME}}Mutation } from '../hooks/useApi';
10
+ {{RELATED_IMPORTS}}
11
+ import type { {{MODEL_NAME}} } from '../types/api';
12
+
13
+ interface {{MODEL_NAME}}ListViewProps {
14
+ onSelect?: (item: {{MODEL_NAME}}) => void;
15
+ onCreate?: () => void;
16
+ }
17
+
18
+ export function {{MODEL_NAME}}ListView({ onSelect, onCreate }: {{MODEL_NAME}}ListViewProps) {
19
+ const { data: items = [], isLoading, error } = use{{PLURAL_MODEL}}Query();
20
+ const deleteItem = useDelete{{MODEL_NAME}}Mutation();
21
+ {{RELATED_HOOKS}}
22
+ const [searchTerm, setSearchTerm] = useState('');
23
+
24
+ const filtered = useMemo(
25
+ () =>
26
+ items.filter((item: {{MODEL_NAME}}) =>
27
+ // Cast to `any` inside JSX context — `Record<string, unknown>`
28
+ // looks like a JSX opening tag to the TSX parser when this
29
+ // expression ends up inside JSX. `any` is safe here: we only
30
+ // read values for substring-matching against the search term.
31
+ Object.values(item as any).some(v =>
32
+ String(v ?? '').toLowerCase().includes(searchTerm.toLowerCase())
33
+ )
34
+ ),
35
+ [items, searchTerm]
36
+ );
37
+
38
+ if (isLoading) return <div className="p-4 text-gray-500">Loading {{PLURAL_LOWER}}…</div>;
39
+ if (error) return <div className="p-4 text-red-600">Error loading {{PLURAL_LOWER}}: {String(error)}</div>;
40
+
41
+ return (
42
+ <div className="p-6 space-y-4">
43
+ <div className="flex items-center justify-between">
44
+ <input
45
+ type="search"
46
+ placeholder="Search {{PLURAL_LOWER}}…"
47
+ value={searchTerm}
48
+ onChange={e => setSearchTerm(e.target.value)}
49
+ className="w-64 rounded border border-gray-300 px-3 py-2 text-sm"
50
+ />
51
+ <button
52
+ type="button"
53
+ onClick={onCreate}
54
+ className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
55
+ >
56
+ + New {{MODEL_NAME}}
57
+ </button>
58
+ </div>
59
+
60
+ {/* Pattern-rendered list body (table + rows). Edit freely. */}
61
+ {{BODY}}
62
+
63
+ {filtered.length === 0 && (
64
+ <div className="p-8 text-center text-gray-400">No {{PLURAL_LOWER}} yet.</div>
65
+ )}
66
+
67
+ {deleteItem.isError && (
68
+ <div className="p-2 text-sm text-red-600">
69
+ Delete failed: {String(deleteItem.error)}
70
+ </div>
71
+ )}
72
+ </div>
73
+ );
74
+ }