@compilr-dev/factory 0.1.6 → 0.1.7
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.
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Generates: server/index.ts, server/routes/{entity}.ts, server/data/{entity}.ts
|
|
5
5
|
*/
|
|
6
6
|
import { toCamelCase, toKebabCase } from '../../model/naming.js';
|
|
7
|
-
import { tsType, fkFieldName, belongsToRels } from './helpers.js';
|
|
7
|
+
import { tsType, fkFieldName, belongsToRels, hasManyRels } from './helpers.js';
|
|
8
8
|
import { generateSeedData } from './seed.js';
|
|
9
9
|
export function generateApiFiles(model) {
|
|
10
10
|
return [
|
|
@@ -71,6 +71,23 @@ function generateDataStore(model, entity) {
|
|
|
71
71
|
}
|
|
72
72
|
typeFields.push(' createdAt: string;');
|
|
73
73
|
typeFields.push(' updatedAt: string;');
|
|
74
|
+
// Collect searchable fields (string + enum types)
|
|
75
|
+
const searchableFields = entity.fields
|
|
76
|
+
.filter((f) => f.type === 'string' || f.type === 'enum')
|
|
77
|
+
.map((f) => `item.${f.name}`);
|
|
78
|
+
const searchFilter = searchableFields.length > 0
|
|
79
|
+
? ` const q = options.search.toLowerCase();
|
|
80
|
+
result = result.filter((item) =>
|
|
81
|
+
[${searchableFields.join(', ')}].some((v) =>
|
|
82
|
+
v !== undefined && String(v).toLowerCase().includes(q),
|
|
83
|
+
),
|
|
84
|
+
);`
|
|
85
|
+
: ` const q = options.search.toLowerCase();
|
|
86
|
+
result = result.filter((item) =>
|
|
87
|
+
Object.values(item).some((v) =>
|
|
88
|
+
v !== undefined && String(v).toLowerCase().includes(q),
|
|
89
|
+
),
|
|
90
|
+
);`;
|
|
74
91
|
const seedData = generateSeedData(model, entity);
|
|
75
92
|
return {
|
|
76
93
|
path: `server/data/${fileName}.ts`,
|
|
@@ -80,6 +97,11 @@ export interface ${typeName}Record {
|
|
|
80
97
|
${typeFields.join('\n')}
|
|
81
98
|
}
|
|
82
99
|
|
|
100
|
+
export interface QueryOptions {
|
|
101
|
+
search?: string;
|
|
102
|
+
filters?: Record<string, string>;
|
|
103
|
+
}
|
|
104
|
+
|
|
83
105
|
${seedData}
|
|
84
106
|
|
|
85
107
|
let items: ${typeName}Record[] = [...${varName}SeedData];
|
|
@@ -93,6 +115,21 @@ export function getById(id: number): ${typeName}Record | undefined {
|
|
|
93
115
|
return items.find((item) => item.id === id);
|
|
94
116
|
}
|
|
95
117
|
|
|
118
|
+
export function query(options: QueryOptions): { data: ${typeName}Record[]; total: number } {
|
|
119
|
+
let result = items;
|
|
120
|
+
if (options.search) {
|
|
121
|
+
${searchFilter}
|
|
122
|
+
}
|
|
123
|
+
if (options.filters) {
|
|
124
|
+
for (const [field, value] of Object.entries(options.filters)) {
|
|
125
|
+
result = result.filter((item) =>
|
|
126
|
+
String((item as Record<string, unknown>)[field]) === value,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { data: result, total: result.length };
|
|
131
|
+
}
|
|
132
|
+
|
|
96
133
|
export function create(data: Omit<${typeName}Record, 'id' | 'createdAt' | 'updatedAt'>): ${typeName}Record {
|
|
97
134
|
const now = new Date().toISOString();
|
|
98
135
|
const item = { ...data, id: nextId++, createdAt: now, updatedAt: now };
|
|
@@ -124,6 +161,7 @@ function generateRoutes(model, entity) {
|
|
|
124
161
|
const varName = toCamelCase(entity.pluralName) + 'Router';
|
|
125
162
|
const dataImport = fileName;
|
|
126
163
|
const btoRels = belongsToRels(entity);
|
|
164
|
+
const hmRels = hasManyRels(entity);
|
|
127
165
|
// Populate belongsTo entities
|
|
128
166
|
const populateImports = btoRels
|
|
129
167
|
.map((rel) => {
|
|
@@ -135,6 +173,17 @@ function generateRoutes(model, entity) {
|
|
|
135
173
|
})
|
|
136
174
|
.filter(Boolean)
|
|
137
175
|
.join('\n');
|
|
176
|
+
// Aliased getAll imports for hasMany targets (used by ?include=)
|
|
177
|
+
const hasManyImports = hmRels
|
|
178
|
+
.map((rel) => {
|
|
179
|
+
const targetEntity = model.entities.find((e) => e.name === rel.target);
|
|
180
|
+
if (!targetEntity)
|
|
181
|
+
return '';
|
|
182
|
+
const targetFile = toKebabCase(targetEntity.pluralName).toLowerCase();
|
|
183
|
+
return `import { getAll as getAll${targetEntity.pluralName} } from '../data/${targetFile}.js';`;
|
|
184
|
+
})
|
|
185
|
+
.filter(Boolean)
|
|
186
|
+
.join('\n');
|
|
138
187
|
const populateLogic = btoRels.length > 0
|
|
139
188
|
? `
|
|
140
189
|
function populate(item: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -151,30 +200,64 @@ ${btoRels
|
|
|
151
200
|
`
|
|
152
201
|
: '';
|
|
153
202
|
const getListReturn = btoRels.length > 0
|
|
154
|
-
? 'res.json(
|
|
155
|
-
: 'res.json(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
203
|
+
? 'res.json({ data: result.data.map((item) => populate(item as unknown as Record<string, unknown>)), total: result.total });'
|
|
204
|
+
: 'res.json({ data: result.data, total: result.total });';
|
|
205
|
+
// GET by ID with optional ?include= for hasMany
|
|
206
|
+
let getByIdBody;
|
|
207
|
+
if (hmRels.length > 0) {
|
|
208
|
+
const includeChecks = hmRels
|
|
209
|
+
.map((rel) => {
|
|
210
|
+
const targetEntity = model.entities.find((e) => e.name === rel.target);
|
|
211
|
+
if (!targetEntity)
|
|
212
|
+
return '';
|
|
213
|
+
const targetVar = toCamelCase(targetEntity.pluralName);
|
|
214
|
+
const fkRel = targetEntity.relationships.find((r) => r.type === 'belongsTo' && r.target === entity.name) ?? { type: 'belongsTo', target: entity.name };
|
|
215
|
+
const fk = fkFieldName(fkRel);
|
|
216
|
+
return ` if (include.includes('${targetVar}')) {
|
|
217
|
+
result.${targetVar} = getAll${targetEntity.pluralName}().filter((r) => r.${fk} === item.id);
|
|
218
|
+
}`;
|
|
219
|
+
})
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.join('\n');
|
|
222
|
+
const populateExpr = btoRels.length > 0 ? 'populate(item as unknown as Record<string, unknown>)' : '{ ...item }';
|
|
223
|
+
getByIdBody = ` const item = getById(Number(req.params.id));
|
|
224
|
+
if (!item) return res.status(404).json({ error: 'Not found' });
|
|
225
|
+
const include = (req.query.include as string ?? '').split(',').filter(Boolean);
|
|
226
|
+
const result: Record<string, unknown> = ${populateExpr};
|
|
227
|
+
${includeChecks}
|
|
228
|
+
res.json(result);`;
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
const getByIdReturn = btoRels.length > 0
|
|
232
|
+
? 'res.json(populate(item as unknown as Record<string, unknown>));'
|
|
233
|
+
: 'res.json(item);';
|
|
234
|
+
getByIdBody = ` const item = getById(Number(req.params.id));
|
|
235
|
+
if (!item) return res.status(404).json({ error: 'Not found' });
|
|
236
|
+
${getByIdReturn}`;
|
|
237
|
+
}
|
|
159
238
|
return {
|
|
160
239
|
path: `server/routes/${fileName}.ts`,
|
|
161
240
|
content: `import { Router } from 'express';
|
|
162
|
-
import { getAll, getById, create, update, remove } from '../data/${dataImport}.js';
|
|
163
|
-
${populateImports}
|
|
241
|
+
import { getAll, getById, query, create, update, remove } from '../data/${dataImport}.js';
|
|
242
|
+
${populateImports}${hasManyImports ? '\n' + hasManyImports : ''}
|
|
164
243
|
|
|
165
244
|
export const ${varName} = Router();
|
|
166
245
|
${populateLogic}
|
|
167
246
|
// GET all
|
|
168
|
-
${varName}.get('/', (
|
|
169
|
-
const
|
|
247
|
+
${varName}.get('/', (req, res) => {
|
|
248
|
+
const search = req.query.search as string | undefined;
|
|
249
|
+
const filters: Record<string, string> = {};
|
|
250
|
+
for (const [key, value] of Object.entries(req.query)) {
|
|
251
|
+
const match = key.match(/^filter\\[(.+)\\]$/);
|
|
252
|
+
if (match && typeof value === 'string') filters[match[1]] = value;
|
|
253
|
+
}
|
|
254
|
+
const result = query({ search, filters });
|
|
170
255
|
${getListReturn}
|
|
171
256
|
});
|
|
172
257
|
|
|
173
258
|
// GET by ID
|
|
174
259
|
${varName}.get('/:id', (req, res) => {
|
|
175
|
-
|
|
176
|
-
if (!item) return res.status(404).json({ error: 'Not found' });
|
|
177
|
-
${getByIdReturn}
|
|
260
|
+
${getByIdBody}
|
|
178
261
|
});
|
|
179
262
|
|
|
180
263
|
// POST create
|
|
@@ -26,7 +26,12 @@ function generateListPage(model, entity) {
|
|
|
26
26
|
const hasFilters = enumFields.length > 0;
|
|
27
27
|
const tableHeaders = entity.fields
|
|
28
28
|
.slice(0, 5)
|
|
29
|
-
.map((f) => ` <th
|
|
29
|
+
.map((f) => ` <th
|
|
30
|
+
className="cursor-pointer select-none px-4 py-2 text-left text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
31
|
+
onClick={() => handleSort('${f.name}')}
|
|
32
|
+
>
|
|
33
|
+
${f.label}{sortField === '${f.name}' ? (sortDirection === 'asc' ? ' ↑' : ' ↓') : ''}
|
|
34
|
+
</th>`)
|
|
30
35
|
.join('\n');
|
|
31
36
|
const tableCells = entity.fields
|
|
32
37
|
.slice(0, 5)
|
|
@@ -45,16 +50,27 @@ ${hasFilters ? `const filterOptions = [\n${filterDefs}\n];\n` : ''}
|
|
|
45
50
|
export default function ${entity.name}List() {
|
|
46
51
|
const [items, setItems] = useState<${entity.name}[]>([]);
|
|
47
52
|
const [view, setView] = useState<'card' | 'list'>('card');
|
|
48
|
-
const [search, setSearch] = useState('')
|
|
53
|
+
const [search, setSearch] = useState('');
|
|
54
|
+
const [sortField, setSortField] = useState<string>('');
|
|
55
|
+
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');${hasFilters ? '\n const [filters, setFilters] = useState<Record<string, string>>({});' : ''}
|
|
49
56
|
|
|
50
57
|
useEffect(() => {
|
|
51
58
|
fetch('${api}')
|
|
52
59
|
.then((r) => r.json())
|
|
53
|
-
.then((data: ${entity.name}[]) => setItems(data));
|
|
60
|
+
.then((res: { data: ${entity.name}[]; total: number }) => setItems(res.data));
|
|
54
61
|
}, []);
|
|
55
62
|
|
|
56
63
|
const onSearch = useCallback((q: string) => setSearch(q), []);
|
|
57
64
|
|
|
65
|
+
const handleSort = useCallback((field: string) => {
|
|
66
|
+
if (sortField === field) {
|
|
67
|
+
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
68
|
+
} else {
|
|
69
|
+
setSortField(field);
|
|
70
|
+
setSortDirection('asc');
|
|
71
|
+
}
|
|
72
|
+
}, [sortField]);
|
|
73
|
+
|
|
58
74
|
const filtered = items.filter((item) => {
|
|
59
75
|
if (search) {
|
|
60
76
|
const q = search.toLowerCase();
|
|
@@ -71,6 +87,14 @@ export default function ${entity.name}List() {
|
|
|
71
87
|
return true;
|
|
72
88
|
});
|
|
73
89
|
|
|
90
|
+
const sorted = [...filtered].sort((a, b) => {
|
|
91
|
+
if (!sortField) return 0;
|
|
92
|
+
const aVal = (a as Record<string, unknown>)[sortField];
|
|
93
|
+
const bVal = (b as Record<string, unknown>)[sortField];
|
|
94
|
+
const cmp = String(aVal ?? '').localeCompare(String(bVal ?? ''), undefined, { numeric: true });
|
|
95
|
+
return sortDirection === 'asc' ? cmp : -cmp;
|
|
96
|
+
});
|
|
97
|
+
|
|
74
98
|
return (
|
|
75
99
|
<div>
|
|
76
100
|
<div className="mb-6 flex items-center justify-between">
|
|
@@ -93,7 +117,7 @@ export default function ${entity.name}List() {
|
|
|
93
117
|
|
|
94
118
|
{view === 'card' ? (
|
|
95
119
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
96
|
-
{
|
|
120
|
+
{sorted.map((item) => (
|
|
97
121
|
<${entity.name}Card key={item.id} item={item} />
|
|
98
122
|
))}
|
|
99
123
|
</div>
|
|
@@ -105,7 +129,7 @@ ${tableHeaders}
|
|
|
105
129
|
</tr>
|
|
106
130
|
</thead>
|
|
107
131
|
<tbody>
|
|
108
|
-
{
|
|
132
|
+
{sorted.map((item) => (
|
|
109
133
|
<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
134
|
${tableCells}
|
|
111
135
|
<td className="px-4 py-2">
|
|
@@ -160,22 +184,44 @@ function generateDetailPage(model, entity) {
|
|
|
160
184
|
const [${stateVar}, set${targetEntity.pluralName}] = useState<${rel.target}[]>([]);`;
|
|
161
185
|
})
|
|
162
186
|
.join('');
|
|
163
|
-
|
|
187
|
+
// Build ?include= param for hasMany targets
|
|
188
|
+
const includeTargets = hmRels
|
|
189
|
+
.map((rel) => {
|
|
190
|
+
const targetEntity = model.entities.find((e) => e.name === rel.target);
|
|
191
|
+
return targetEntity ? toCamelCase(targetEntity.pluralName) : '';
|
|
192
|
+
})
|
|
193
|
+
.filter(Boolean);
|
|
194
|
+
const includeParam = includeTargets.length > 0 ? `?include=${includeTargets.join(',')}` : '';
|
|
195
|
+
const hasManySetters = hmRels
|
|
164
196
|
.map((rel) => {
|
|
165
197
|
const targetEntity = model.entities.find((e) => e.name === rel.target);
|
|
166
198
|
if (!targetEntity)
|
|
167
199
|
return '';
|
|
168
|
-
const
|
|
169
|
-
|
|
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))));`;
|
|
200
|
+
const varName = toCamelCase(targetEntity.pluralName);
|
|
201
|
+
return ` set${targetEntity.pluralName}(data.${varName} ?? []);`;
|
|
177
202
|
})
|
|
178
|
-
.join('');
|
|
203
|
+
.join('\n');
|
|
204
|
+
// Build the useEffect block
|
|
205
|
+
let useEffectBlock;
|
|
206
|
+
if (hmRels.length > 0) {
|
|
207
|
+
useEffectBlock =
|
|
208
|
+
' useEffect(() => {\n' +
|
|
209
|
+
` fetch(\`${api}/\${id as string}${includeParam}\`)\n` +
|
|
210
|
+
' .then((r) => r.json())\n' +
|
|
211
|
+
' .then((data: Record<string, unknown>) => {\n' +
|
|
212
|
+
` setItem(data as unknown as ${entity.name});\n` +
|
|
213
|
+
hasManySetters +
|
|
214
|
+
'\n });\n' +
|
|
215
|
+
' }, [id]);';
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
useEffectBlock =
|
|
219
|
+
' useEffect(() => {\n' +
|
|
220
|
+
` fetch(\`${api}/\${id as string}\`)\n` +
|
|
221
|
+
' .then((r) => r.json())\n' +
|
|
222
|
+
` .then((data: ${entity.name}) => setItem(data));\n` +
|
|
223
|
+
' }, [id]);';
|
|
224
|
+
}
|
|
179
225
|
const hasManyImports = hmRels.map((rel) => rel.target).join(', ');
|
|
180
226
|
const hasManyTypeImport = hmRels.length > 0 ? `, ${hasManyImports}` : '';
|
|
181
227
|
const hasManyRender = hmRels
|
|
@@ -217,11 +263,7 @@ export default function ${entity.name}Detail() {
|
|
|
217
263
|
const navigate = useNavigate();
|
|
218
264
|
const [item, setItem] = useState<${entity.name} | null>(null);${hasManySection}
|
|
219
265
|
|
|
220
|
-
|
|
221
|
-
fetch(\`${api}/\${id as string}\`)
|
|
222
|
-
.then((r) => r.json())
|
|
223
|
-
.then((data: ${entity.name}) => setItem(data));${hasManyFetches}
|
|
224
|
-
}, [id]);
|
|
266
|
+
${useEffectBlock}
|
|
225
267
|
|
|
226
268
|
if (!item) return <div className="p-6">Loading...</div>;
|
|
227
269
|
|
|
@@ -330,7 +372,7 @@ function generateForm(model, entity) {
|
|
|
330
372
|
if (!targetEntity)
|
|
331
373
|
return '';
|
|
332
374
|
const targetApi = apiPath(targetEntity);
|
|
333
|
-
return ` fetch('${targetApi}').then((r) => r.json()).then((
|
|
375
|
+
return ` fetch('${targetApi}').then((r) => r.json()).then((res: { data: ${rel.target}[]; total: number }) => set${rel.target}Options(res.data));`;
|
|
334
376
|
})
|
|
335
377
|
.join('\n');
|
|
336
378
|
const relTypeImports = btoRels.map((r) => r.target);
|
|
@@ -4,8 +4,34 @@
|
|
|
4
4
|
* Generates src/types/index.ts with interfaces for each entity.
|
|
5
5
|
*/
|
|
6
6
|
import { tsType, fkFieldName, belongsToRels } from './helpers.js';
|
|
7
|
+
const SHARED_TYPES = `// --- Shared API Types ---
|
|
8
|
+
|
|
9
|
+
export interface PaginatedResponse<T> {
|
|
10
|
+
data: T[];
|
|
11
|
+
total: number;
|
|
12
|
+
page: number;
|
|
13
|
+
pageSize: number;
|
|
14
|
+
totalPages: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ApiResponse<T> {
|
|
18
|
+
data: T;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ApiListResponse<T> {
|
|
23
|
+
data: T[];
|
|
24
|
+
total: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SortParams {
|
|
28
|
+
field: string;
|
|
29
|
+
direction: 'asc' | 'desc';
|
|
30
|
+
}`;
|
|
7
31
|
export function generateTypesFile(model) {
|
|
8
32
|
const lines = ['// Auto-generated TypeScript types from Application Model', ''];
|
|
33
|
+
lines.push(SHARED_TYPES);
|
|
34
|
+
lines.push('');
|
|
9
35
|
for (const entity of model.entities) {
|
|
10
36
|
lines.push(generateInterface(entity));
|
|
11
37
|
lines.push('');
|