@compilr-dev/factory 0.1.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.
Files changed (64) hide show
  1. package/README.md +131 -0
  2. package/dist/factory/file-writer.d.ts +11 -0
  3. package/dist/factory/file-writer.js +21 -0
  4. package/dist/factory/list-toolkits-tool.d.ts +12 -0
  5. package/dist/factory/list-toolkits-tool.js +35 -0
  6. package/dist/factory/registry.d.ts +15 -0
  7. package/dist/factory/registry.js +28 -0
  8. package/dist/factory/scaffold-tool.d.ts +21 -0
  9. package/dist/factory/scaffold-tool.js +95 -0
  10. package/dist/factory/skill.d.ts +14 -0
  11. package/dist/factory/skill.js +146 -0
  12. package/dist/factory/tools.d.ts +14 -0
  13. package/dist/factory/tools.js +17 -0
  14. package/dist/index.d.ts +22 -0
  15. package/dist/index.js +25 -0
  16. package/dist/model/defaults.d.ts +10 -0
  17. package/dist/model/defaults.js +68 -0
  18. package/dist/model/naming.d.ts +12 -0
  19. package/dist/model/naming.js +80 -0
  20. package/dist/model/operations-entity.d.ts +35 -0
  21. package/dist/model/operations-entity.js +110 -0
  22. package/dist/model/operations-field.d.ts +33 -0
  23. package/dist/model/operations-field.js +104 -0
  24. package/dist/model/operations-relationship.d.ts +19 -0
  25. package/dist/model/operations-relationship.js +90 -0
  26. package/dist/model/operations-section.d.ts +32 -0
  27. package/dist/model/operations-section.js +35 -0
  28. package/dist/model/operations.d.ts +12 -0
  29. package/dist/model/operations.js +63 -0
  30. package/dist/model/persistence.d.ts +19 -0
  31. package/dist/model/persistence.js +40 -0
  32. package/dist/model/schema.d.ts +15 -0
  33. package/dist/model/schema.js +269 -0
  34. package/dist/model/tools.d.ts +14 -0
  35. package/dist/model/tools.js +380 -0
  36. package/dist/model/types.d.ts +70 -0
  37. package/dist/model/types.js +8 -0
  38. package/dist/toolkits/react-node/api.d.ts +8 -0
  39. package/dist/toolkits/react-node/api.js +198 -0
  40. package/dist/toolkits/react-node/config.d.ts +9 -0
  41. package/dist/toolkits/react-node/config.js +120 -0
  42. package/dist/toolkits/react-node/dashboard.d.ts +8 -0
  43. package/dist/toolkits/react-node/dashboard.js +60 -0
  44. package/dist/toolkits/react-node/entity.d.ts +8 -0
  45. package/dist/toolkits/react-node/entity.js +469 -0
  46. package/dist/toolkits/react-node/helpers.d.ts +27 -0
  47. package/dist/toolkits/react-node/helpers.js +71 -0
  48. package/dist/toolkits/react-node/index.d.ts +8 -0
  49. package/dist/toolkits/react-node/index.js +52 -0
  50. package/dist/toolkits/react-node/router.d.ts +8 -0
  51. package/dist/toolkits/react-node/router.js +49 -0
  52. package/dist/toolkits/react-node/seed.d.ts +11 -0
  53. package/dist/toolkits/react-node/seed.js +144 -0
  54. package/dist/toolkits/react-node/shared.d.ts +7 -0
  55. package/dist/toolkits/react-node/shared.js +119 -0
  56. package/dist/toolkits/react-node/shell.d.ts +8 -0
  57. package/dist/toolkits/react-node/shell.js +152 -0
  58. package/dist/toolkits/react-node/static.d.ts +8 -0
  59. package/dist/toolkits/react-node/static.js +105 -0
  60. package/dist/toolkits/react-node/types-gen.d.ts +8 -0
  61. package/dist/toolkits/react-node/types-gen.js +31 -0
  62. package/dist/toolkits/types.d.ts +22 -0
  63. package/dist/toolkits/types.js +6 -0
  64. package/package.json +51 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * React+Node Toolkit — Configuration Files Generator
3
+ *
4
+ * Generates: package.json, vite.config.ts, tailwind.config.js,
5
+ * tsconfig.json, postcss.config.js
6
+ */
7
+ import { toKebabCase } from '../../model/naming.js';
8
+ export function generateConfigFiles(model) {
9
+ const appSlug = toKebabCase(model.identity.name);
10
+ return [
11
+ generatePackageJson(appSlug),
12
+ generateViteConfig(),
13
+ generateTailwindConfig(model),
14
+ generateTsConfig(),
15
+ generatePostCssConfig(),
16
+ ];
17
+ }
18
+ function generatePackageJson(appSlug) {
19
+ const pkg = {
20
+ name: appSlug,
21
+ version: '0.1.0',
22
+ private: true,
23
+ type: 'module',
24
+ scripts: {
25
+ dev: 'concurrently "vite" "tsx watch server/index.ts"',
26
+ build: 'vite build',
27
+ preview: 'vite preview',
28
+ server: 'tsx watch server/index.ts',
29
+ },
30
+ dependencies: {
31
+ react: '^18.3.1',
32
+ 'react-dom': '^18.3.1',
33
+ 'react-router-dom': '^6.28.0',
34
+ },
35
+ devDependencies: {
36
+ '@types/cors': '^2.8.17',
37
+ '@types/express': '^5.0.0',
38
+ '@types/react': '^18.3.12',
39
+ '@types/react-dom': '^18.3.1',
40
+ '@vitejs/plugin-react': '^4.3.4',
41
+ autoprefixer: '^10.4.20',
42
+ concurrently: '^9.1.0',
43
+ cors: '^2.8.5',
44
+ express: '^4.21.1',
45
+ postcss: '^8.4.49',
46
+ tailwindcss: '^3.4.15',
47
+ tsx: '^4.19.2',
48
+ typescript: '^5.6.3',
49
+ vite: '^6.0.0',
50
+ },
51
+ };
52
+ return { path: 'package.json', content: JSON.stringify(pkg, null, 2) + '\n' };
53
+ }
54
+ function generateViteConfig() {
55
+ return {
56
+ path: 'vite.config.ts',
57
+ content: `import { defineConfig } from 'vite';
58
+ import react from '@vitejs/plugin-react';
59
+
60
+ export default defineConfig({
61
+ plugins: [react()],
62
+ server: {
63
+ proxy: {
64
+ '/api': 'http://localhost:3001',
65
+ },
66
+ },
67
+ });
68
+ `,
69
+ };
70
+ }
71
+ function generateTailwindConfig(model) {
72
+ return {
73
+ path: 'tailwind.config.js',
74
+ content: `/** @type {import('tailwindcss').Config} */
75
+ export default {
76
+ content: ['./index.html', './src/**/*.{ts,tsx}'],
77
+ darkMode: 'class',
78
+ theme: {
79
+ extend: {
80
+ colors: {
81
+ primary: '${model.theme.primaryColor}',
82
+ },
83
+ },
84
+ },
85
+ plugins: [],
86
+ };
87
+ `,
88
+ };
89
+ }
90
+ function generateTsConfig() {
91
+ const config = {
92
+ compilerOptions: {
93
+ target: 'ES2022',
94
+ module: 'ESNext',
95
+ moduleResolution: 'bundler',
96
+ jsx: 'react-jsx',
97
+ strict: true,
98
+ esModuleInterop: true,
99
+ skipLibCheck: true,
100
+ forceConsistentCasingInFileNames: true,
101
+ resolveJsonModule: true,
102
+ isolatedModules: true,
103
+ noEmit: true,
104
+ },
105
+ include: ['src', 'server'],
106
+ };
107
+ return { path: 'tsconfig.json', content: JSON.stringify(config, null, 2) + '\n' };
108
+ }
109
+ function generatePostCssConfig() {
110
+ return {
111
+ path: 'postcss.config.js',
112
+ content: `export default {
113
+ plugins: {
114
+ tailwindcss: {},
115
+ autoprefixer: {},
116
+ },
117
+ };
118
+ `,
119
+ };
120
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * React+Node Toolkit — Dashboard Generator
3
+ *
4
+ * Generates Dashboard.tsx (only when features.dashboard = true).
5
+ */
6
+ import type { ApplicationModel } from '../../model/types.js';
7
+ import type { FactoryFile } from '../types.js';
8
+ export declare function generateDashboard(model: ApplicationModel): FactoryFile[];
@@ -0,0 +1,60 @@
1
+ /**
2
+ * React+Node Toolkit — Dashboard Generator
3
+ *
4
+ * Generates Dashboard.tsx (only when features.dashboard = true).
5
+ */
6
+ import { toKebabCase, toCamelCase } from '../../model/naming.js';
7
+ export function generateDashboard(model) {
8
+ if (!model.features.dashboard)
9
+ return [];
10
+ const fetchCalls = model.entities
11
+ .map((e) => {
12
+ const apiUrl = '/api/' + toKebabCase(e.pluralName).toLowerCase();
13
+ return ` fetch('${apiUrl}').then(r => r.json()).then((d: ${e.name}[]) => set${e.name}Count(d.length)),`;
14
+ })
15
+ .join('\n');
16
+ const stateLines = model.entities
17
+ .map((e) => ` const [${toCamelCase(e.name)}Count, set${e.name}Count] = useState(0);`)
18
+ .join('\n');
19
+ const typeImports = model.entities.map((e) => e.name).join(', ');
20
+ const cards = model.entities
21
+ .map((e) => {
22
+ const path = '/' + toKebabCase(e.pluralName).toLowerCase();
23
+ return ` <Link to="${path}" className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm hover:shadow-md dark:border-gray-700 dark:bg-gray-800">
24
+ <div className="text-3xl">${e.icon}</div>
25
+ <div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{${toCamelCase(e.name)}Count}</div>
26
+ <div className="text-sm text-gray-500 dark:text-gray-400">${e.pluralName}</div>
27
+ </Link>`;
28
+ })
29
+ .join('\n');
30
+ return [
31
+ {
32
+ path: 'src/pages/Dashboard.tsx',
33
+ content: `import { useState, useEffect } from 'react';
34
+ import { Link } from 'react-router-dom';
35
+ import type { ${typeImports} } from '../types';
36
+
37
+ export default function Dashboard() {
38
+ ${stateLines}
39
+
40
+ useEffect(() => {
41
+ Promise.all([
42
+ ${fetchCalls}
43
+ ]);
44
+ }, []);
45
+
46
+ return (
47
+ <div>
48
+ <h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
49
+ Welcome to ${model.identity.name}
50
+ </h1>
51
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-${String(Math.min(model.entities.length, 4))}">
52
+ ${cards}
53
+ </div>
54
+ </div>
55
+ );
56
+ }
57
+ `,
58
+ },
59
+ ];
60
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * React+Node Toolkit — Entity Pages Generator
3
+ *
4
+ * Generates per entity: List page, Detail page, Card component, Form component.
5
+ */
6
+ import type { ApplicationModel } from '../../model/types.js';
7
+ import type { FactoryFile } from '../types.js';
8
+ export declare function generateEntityFiles(model: ApplicationModel): FactoryFile[];
@@ -0,0 +1,469 @@
1
+ /**
2
+ * React+Node Toolkit — Entity Pages Generator
3
+ *
4
+ * Generates per entity: List page, Detail page, Card component, Form component.
5
+ */
6
+ import { toCamelCase, toKebabCase } from '../../model/naming.js';
7
+ import { fkFieldName, belongsToRels, hasManyRels, inputType, apiPath } from './helpers.js';
8
+ export function generateEntityFiles(model) {
9
+ return model.entities.flatMap((entity) => [
10
+ generateListPage(model, entity),
11
+ generateDetailPage(model, entity),
12
+ generateCard(entity),
13
+ generateForm(model, entity),
14
+ ]);
15
+ }
16
+ // =============================================================================
17
+ // List Page
18
+ // =============================================================================
19
+ function generateListPage(model, entity) {
20
+ const api = apiPath(entity);
21
+ const routeBase = toKebabCase(entity.pluralName).toLowerCase();
22
+ const enumFields = entity.fields.filter((f) => f.type === 'enum' && f.enumValues?.length);
23
+ const filterDefs = enumFields
24
+ .map((f) => ` { label: '${f.label}', field: '${f.name}', values: [${(f.enumValues ?? []).map((v) => `'${v}'`).join(', ')}] },`)
25
+ .join('\n');
26
+ const hasFilters = enumFields.length > 0;
27
+ const tableHeaders = entity.fields
28
+ .slice(0, 5)
29
+ .map((f) => ` <th className="px-4 py-2 text-left text-sm font-medium text-gray-500 dark:text-gray-400">${f.label}</th>`)
30
+ .join('\n');
31
+ const tableCells = entity.fields
32
+ .slice(0, 5)
33
+ .map((f) => ` <td className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">{String(item.${f.name})}</td>`)
34
+ .join('\n');
35
+ return {
36
+ path: `src/pages/${entity.name}List.tsx`,
37
+ content: `import { useState, useEffect, useCallback } from 'react';
38
+ import { Link } from 'react-router-dom';
39
+ import type { ${entity.name} } from '../types';
40
+ import ViewToggle from '../components/shared/ViewToggle';
41
+ import SearchBar from '../components/shared/SearchBar';${hasFilters ? "\nimport FilterBar from '../components/shared/FilterBar';" : ''}
42
+ import ${entity.name}Card from '../components/${routeBase}/${entity.name}Card';
43
+
44
+ ${hasFilters ? `const filterOptions = [\n${filterDefs}\n];\n` : ''}
45
+ export default function ${entity.name}List() {
46
+ const [items, setItems] = useState<${entity.name}[]>([]);
47
+ const [view, setView] = useState<'card' | 'list'>('card');
48
+ const [search, setSearch] = useState('');${hasFilters ? '\n const [filters, setFilters] = useState<Record<string, string>>({});' : ''}
49
+
50
+ useEffect(() => {
51
+ fetch('${api}')
52
+ .then((r) => r.json())
53
+ .then((data: ${entity.name}[]) => setItems(data));
54
+ }, []);
55
+
56
+ const onSearch = useCallback((q: string) => setSearch(q), []);
57
+
58
+ const filtered = items.filter((item) => {
59
+ if (search) {
60
+ const q = search.toLowerCase();
61
+ const match = Object.values(item).some((v) =>
62
+ String(v).toLowerCase().includes(q),
63
+ );
64
+ if (!match) return false;
65
+ }${hasFilters
66
+ ? `
67
+ for (const [field, value] of Object.entries(filters)) {
68
+ if (value && String((item as Record<string, unknown>)[field]) !== value) return false;
69
+ }`
70
+ : ''}
71
+ return true;
72
+ });
73
+
74
+ return (
75
+ <div>
76
+ <div className="mb-6 flex items-center justify-between">
77
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-white">${entity.icon} ${entity.pluralName}</h1>
78
+ <div className="flex items-center gap-3">
79
+ <ViewToggle view={view} onChange={setView} />
80
+ <Link
81
+ to="/${routeBase}/new"
82
+ className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:opacity-90"
83
+ >
84
+ + New ${entity.name}
85
+ </Link>
86
+ </div>
87
+ </div>
88
+
89
+ <div className="mb-4 flex flex-wrap items-center gap-3">
90
+ <div className="w-64">
91
+ <SearchBar onSearch={onSearch} placeholder="Search ${entity.pluralName.toLowerCase()}..." />${hasFilters ? '\n </div>\n <FilterBar filters={filterOptions} active={filters} onChange={(f, v) => setFilters({ ...filters, [f]: v })} />' : '\n </div>'}
92
+ </div>
93
+
94
+ {view === 'card' ? (
95
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
96
+ {filtered.map((item) => (
97
+ <${entity.name}Card key={item.id} item={item} />
98
+ ))}
99
+ </div>
100
+ ) : (
101
+ <table className="w-full border-collapse">
102
+ <thead>
103
+ <tr className="border-b border-gray-200 dark:border-gray-700">
104
+ ${tableHeaders}
105
+ </tr>
106
+ </thead>
107
+ <tbody>
108
+ {filtered.map((item) => (
109
+ <tr key={item.id} className="border-b border-gray-100 hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-800/50">
110
+ ${tableCells}
111
+ <td className="px-4 py-2">
112
+ <Link to={\`/${routeBase}/\${String(item.id)}\`} className="text-sm text-primary hover:underline">View</Link>
113
+ </td>
114
+ </tr>
115
+ ))}
116
+ </tbody>
117
+ </table>
118
+ )}
119
+ </div>
120
+ );
121
+ }
122
+ `,
123
+ };
124
+ }
125
+ // =============================================================================
126
+ // Detail Page
127
+ // =============================================================================
128
+ function generateDetailPage(model, entity) {
129
+ const api = apiPath(entity);
130
+ const routeBase = toKebabCase(entity.pluralName).toLowerCase();
131
+ const fieldRows = entity.fields
132
+ .map((f) => ` <div>
133
+ <dt className="text-sm font-medium text-gray-500 dark:text-gray-400">${f.label}</dt>
134
+ <dd className="mt-1 text-gray-900 dark:text-white">{String(item.${f.name}${f.required ? '' : " ?? '—'"})}</dd>
135
+ </div>`)
136
+ .join('\n');
137
+ const btoRels = belongsToRels(entity);
138
+ const hmRels = hasManyRels(entity);
139
+ const relLinks = btoRels
140
+ .map((rel) => {
141
+ const fk = fkFieldName(rel);
142
+ const targetRoute = toKebabCase(model.entities.find((e) => e.name === rel.target)?.pluralName ?? rel.target).toLowerCase();
143
+ return ` <div>
144
+ <dt className="text-sm font-medium text-gray-500 dark:text-gray-400">${rel.target}</dt>
145
+ <dd className="mt-1">
146
+ <Link to={\`/${targetRoute}/\${String(item.${fk})}\`} className="text-primary hover:underline">
147
+ View ${rel.target}
148
+ </Link>
149
+ </dd>
150
+ </div>`;
151
+ })
152
+ .join('\n');
153
+ const hasManySection = hmRels
154
+ .map((rel) => {
155
+ const targetEntity = model.entities.find((e) => e.name === rel.target);
156
+ if (!targetEntity)
157
+ return '';
158
+ const stateVar = toCamelCase(targetEntity.pluralName);
159
+ return `
160
+ const [${stateVar}, set${targetEntity.pluralName}] = useState<${rel.target}[]>([]);`;
161
+ })
162
+ .join('');
163
+ const hasManyFetches = hmRels
164
+ .map((rel) => {
165
+ const targetEntity = model.entities.find((e) => e.name === rel.target);
166
+ if (!targetEntity)
167
+ return '';
168
+ const fetchApi = apiPath(targetEntity);
169
+ const fk = fkFieldName(targetEntity.relationships.find((r) => r.type === 'belongsTo' && r.target === entity.name) ?? {
170
+ type: 'belongsTo',
171
+ target: entity.name,
172
+ });
173
+ return `
174
+ fetch('${fetchApi}')
175
+ .then((r) => r.json())
176
+ .then((data: ${rel.target}[]) => set${targetEntity.pluralName}(data.filter((d) => d.${fk} === Number(id))));`;
177
+ })
178
+ .join('');
179
+ const hasManyImports = hmRels.map((rel) => rel.target).join(', ');
180
+ const hasManyTypeImport = hmRels.length > 0 ? `, ${hasManyImports}` : '';
181
+ const hasManyRender = hmRels
182
+ .map((rel) => {
183
+ const targetEntity = model.entities.find((e) => e.name === rel.target);
184
+ if (!targetEntity)
185
+ return '';
186
+ const targetRoute = toKebabCase(targetEntity.pluralName).toLowerCase();
187
+ const varName = toCamelCase(targetEntity.pluralName);
188
+ const firstField = targetEntity.fields[0]?.name ?? 'id';
189
+ return `
190
+ <div className="mt-8">
191
+ <h2 className="mb-4 text-lg font-bold text-gray-900 dark:text-white">${targetEntity.icon} ${targetEntity.pluralName}</h2>
192
+ <div className="space-y-2">
193
+ {${varName}.map((rel) => (
194
+ <Link
195
+ key={rel.id}
196
+ to={\`/${targetRoute}/\${String(rel.id)}\`}
197
+ className="block rounded-lg border border-gray-200 p-3 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
198
+ >
199
+ {String(rel.${firstField})}
200
+ </Link>
201
+ ))}
202
+ {${varName}.length === 0 && (
203
+ <p className="text-sm text-gray-500">No ${targetEntity.pluralName.toLowerCase()} found.</p>
204
+ )}
205
+ </div>
206
+ </div>`;
207
+ })
208
+ .join('');
209
+ return {
210
+ path: `src/pages/${entity.name}Detail.tsx`,
211
+ content: `import { useState, useEffect } from 'react';
212
+ import { useParams, useNavigate, Link } from 'react-router-dom';
213
+ import type { ${entity.name}${hasManyTypeImport} } from '../types';
214
+
215
+ export default function ${entity.name}Detail() {
216
+ const { id } = useParams();
217
+ const navigate = useNavigate();
218
+ const [item, setItem] = useState<${entity.name} | null>(null);${hasManySection}
219
+
220
+ useEffect(() => {
221
+ fetch(\`${api}/\${id as string}\`)
222
+ .then((r) => r.json())
223
+ .then((data: ${entity.name}) => setItem(data));${hasManyFetches}
224
+ }, [id]);
225
+
226
+ if (!item) return <div className="p-6">Loading...</div>;
227
+
228
+ const handleDelete = () => {
229
+ if (!confirm('Are you sure you want to delete this ${entity.name.toLowerCase()}?')) return;
230
+ fetch(\`${api}/\${id as string}\`, { method: 'DELETE' })
231
+ .then(() => navigate('/${routeBase}'));
232
+ };
233
+
234
+ return (
235
+ <div>
236
+ <div className="mb-6 flex items-center justify-between">
237
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-white">${entity.icon} ${entity.name} Detail</h1>
238
+ <div className="flex gap-2">
239
+ <Link
240
+ to={\`/${routeBase}/\${id as string}/edit\`}
241
+ className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:opacity-90"
242
+ >
243
+ Edit
244
+ </Link>
245
+ <button
246
+ onClick={handleDelete}
247
+ className="rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white hover:opacity-90"
248
+ >
249
+ Delete
250
+ </button>
251
+ </div>
252
+ </div>
253
+
254
+ <dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
255
+ ${fieldRows}
256
+ ${relLinks}
257
+ </dl>${hasManyRender}
258
+ </div>
259
+ );
260
+ }
261
+ `,
262
+ };
263
+ }
264
+ // =============================================================================
265
+ // Card Component
266
+ // =============================================================================
267
+ function generateCard(entity) {
268
+ const routeBase = toKebabCase(entity.pluralName).toLowerCase();
269
+ const previewFields = entity.fields.slice(0, 4);
270
+ const fieldDisplay = previewFields
271
+ .map((f) => ` <p className="text-sm text-gray-600 dark:text-gray-300">
272
+ <span className="font-medium">${f.label}:</span> {String(item.${f.name})}
273
+ </p>`)
274
+ .join('\n');
275
+ return {
276
+ path: `src/components/${routeBase}/${entity.name}Card.tsx`,
277
+ content: `import { Link } from 'react-router-dom';
278
+ import type { ${entity.name} } from '../../types';
279
+
280
+ interface ${entity.name}CardProps {
281
+ item: ${entity.name};
282
+ }
283
+
284
+ export default function ${entity.name}Card({ item }: ${entity.name}CardProps) {
285
+ return (
286
+ <Link
287
+ to={\`/${routeBase}/\${String(item.id)}\`}
288
+ className="block rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
289
+ >
290
+ <div className="mb-2 flex items-center gap-2">
291
+ <span className="text-lg">${entity.icon}</span>
292
+ <span className="font-medium text-gray-900 dark:text-white">
293
+ {String(item.${entity.fields[0]?.name ?? 'id'})}
294
+ </span>
295
+ </div>
296
+ <div className="space-y-1">
297
+ ${fieldDisplay}
298
+ </div>
299
+ </Link>
300
+ );
301
+ }
302
+ `,
303
+ };
304
+ }
305
+ // =============================================================================
306
+ // Form Component
307
+ // =============================================================================
308
+ function generateForm(model, entity) {
309
+ const routeBase = toKebabCase(entity.pluralName).toLowerCase();
310
+ const api = apiPath(entity);
311
+ const btoRels = belongsToRels(entity);
312
+ // State for belongsTo dropdowns
313
+ const relStateLines = btoRels
314
+ .map((rel) => {
315
+ const varName = toCamelCase(rel.target) + 'Options';
316
+ return ` const [${varName}, set${rel.target}Options] = useState<${rel.target}[]>([]);`;
317
+ })
318
+ .join('\n');
319
+ const relFetches = btoRels
320
+ .map((rel) => {
321
+ const targetEntity = model.entities.find((e) => e.name === rel.target);
322
+ if (!targetEntity)
323
+ return '';
324
+ const targetApi = apiPath(targetEntity);
325
+ return ` fetch('${targetApi}').then((r) => r.json()).then((d: ${rel.target}[]) => set${rel.target}Options(d));`;
326
+ })
327
+ .join('\n');
328
+ const relTypeImports = btoRels.map((r) => r.target);
329
+ const allImports = [entity.name, ...relTypeImports];
330
+ // Initial form state
331
+ const formFields = [];
332
+ for (const field of entity.fields) {
333
+ const defaultVal = field.type === 'boolean' ? 'false' : field.type === 'number' ? '0' : "''";
334
+ formFields.push(` ${field.name}: ${defaultVal},`);
335
+ }
336
+ for (const rel of btoRels) {
337
+ formFields.push(` ${fkFieldName(rel)}: 0,`);
338
+ }
339
+ // Form inputs
340
+ const formInputs = entity.fields.map((f) => generateFormInput(f)).join('\n');
341
+ const relInputs = btoRels
342
+ .map((rel) => {
343
+ const fk = fkFieldName(rel);
344
+ const optionsVar = toCamelCase(rel.target) + 'Options';
345
+ const firstField = model.entities.find((e) => e.name === rel.target)?.fields[0]?.name ?? 'id';
346
+ return ` <div>
347
+ <label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">${rel.target}</label>
348
+ <select
349
+ value={String(form.${fk})}
350
+ onChange={(e) => setForm({ ...form, ${fk}: Number(e.target.value) })}
351
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
352
+ >
353
+ <option value="0">Select ${rel.target}...</option>
354
+ {${optionsVar}.map((opt) => (
355
+ <option key={opt.id} value={opt.id}>{String(opt.${firstField})}</option>
356
+ ))}
357
+ </select>
358
+ </div>`;
359
+ })
360
+ .join('\n');
361
+ return {
362
+ path: `src/components/${routeBase}/${entity.name}Form.tsx`,
363
+ content: `import { useState, useEffect } from 'react';
364
+ import { useParams, useNavigate } from 'react-router-dom';
365
+ import type { ${allImports.join(', ')} } from '../../types';
366
+
367
+ export default function ${entity.name}Form() {
368
+ const { id } = useParams();
369
+ const navigate = useNavigate();
370
+ const isEdit = Boolean(id);
371
+
372
+ const [form, setForm] = useState({
373
+ ${formFields.join('\n')}
374
+ });
375
+ ${relStateLines ? '\n' + relStateLines : ''}
376
+
377
+ useEffect(() => {
378
+ if (isEdit) {
379
+ fetch(\`${api}/\${id as string}\`)
380
+ .then((r) => r.json())
381
+ .then((data: ${entity.name}) => setForm(data as typeof form));
382
+ }
383
+ ${relFetches}
384
+ }, [id, isEdit]);
385
+
386
+ const handleSubmit = (e: React.FormEvent) => {
387
+ e.preventDefault();
388
+ const method = isEdit ? 'PUT' : 'POST';
389
+ const url = isEdit ? \`${api}/\${id as string}\` : '${api}';
390
+ fetch(url, {
391
+ method,
392
+ headers: { 'Content-Type': 'application/json' },
393
+ body: JSON.stringify(form),
394
+ }).then(() => navigate('/${routeBase}'));
395
+ };
396
+
397
+ return (
398
+ <div>
399
+ <h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
400
+ {isEdit ? 'Edit' : 'New'} ${entity.name}
401
+ </h1>
402
+ <form onSubmit={handleSubmit} className="max-w-lg space-y-4">
403
+ ${formInputs}
404
+ ${relInputs}
405
+ <div className="flex gap-2 pt-4">
406
+ <button
407
+ type="submit"
408
+ className="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white hover:opacity-90"
409
+ >
410
+ {isEdit ? 'Update' : 'Create'}
411
+ </button>
412
+ <button
413
+ type="button"
414
+ onClick={() => navigate('/${routeBase}')}
415
+ className="rounded-lg border border-gray-300 px-6 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300"
416
+ >
417
+ Cancel
418
+ </button>
419
+ </div>
420
+ </form>
421
+ </div>
422
+ );
423
+ }
424
+ `,
425
+ };
426
+ }
427
+ function generateFormInput(field) {
428
+ if (field.type === 'boolean') {
429
+ return ` <div className="flex items-center gap-2">
430
+ <input
431
+ type="checkbox"
432
+ checked={Boolean(form.${field.name})}
433
+ onChange={(e) => setForm({ ...form, ${field.name}: e.target.checked })}
434
+ className="h-4 w-4 rounded border-gray-300"
435
+ />
436
+ <label className="text-sm font-medium text-gray-700 dark:text-gray-300">${field.label}</label>
437
+ </div>`;
438
+ }
439
+ if (field.type === 'enum') {
440
+ const options = (field.enumValues ?? [])
441
+ .map((v) => ` <option value="${v}">${v}</option>`)
442
+ .join('\n');
443
+ return ` <div>
444
+ <label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">${field.label}</label>
445
+ <select
446
+ value={String(form.${field.name})}
447
+ onChange={(e) => setForm({ ...form, ${field.name}: e.target.value })}${field.required ? '\n required' : ''}
448
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
449
+ >
450
+ <option value="">Select...</option>
451
+ ${options}
452
+ </select>
453
+ </div>`;
454
+ }
455
+ const type = inputType(field);
456
+ const valueExpr = field.type === 'number' ? `Number(form.${field.name})` : `String(form.${field.name})`;
457
+ const changeExpr = field.type === 'number'
458
+ ? `setForm({ ...form, ${field.name}: Number(e.target.value) })`
459
+ : `setForm({ ...form, ${field.name}: e.target.value })`;
460
+ return ` <div>
461
+ <label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">${field.label}</label>
462
+ <input
463
+ type="${type}"
464
+ value={${valueExpr}}
465
+ onChange={(e) => ${changeExpr}}${field.required ? '\n required' : ''}
466
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
467
+ />
468
+ </div>`;
469
+ }