generate angular with dynamic columns

This commit is contained in:
Roland Schneider 2025-11-19 11:41:10 +01:00
parent 620d42a1fd
commit a6d495545a
8 changed files with 106 additions and 145 deletions

View File

@ -8,8 +8,15 @@ import * as fs from 'fs';
import { promises as fsPromises } from 'fs';
import { TableColumn } from 'typeorm';
// Interface for structured field metadata passed to templates
interface FieldDefinition {
name: string;
tsType: string;
}
// Naming conventions used throughout the templates
interface NamingConvention {
[key: string]: string; // This index signature makes it compatible with Record<string, string>
[key: string]: string;
singular: string;
plural: string;
pascal: string;
@ -30,20 +37,34 @@ export class AngularGeneratorService {
console.log(`Generating Angular module for table: ${tableName}...`);
const names = this.getNamingConvention(tableName);
const numericFields = (columns || [])
.filter(c => {
const tsType = this.mapDbToTsType(c.type);
return tsType === 'number' && !c.isPrimary;
})
.map(c => c.name);
const fields: FieldDefinition[] = (columns || [])
.map(c => ({
name: c.name,
tsType: this.mapDbToTsType(c.type),
}));
names['modelProperties'] = fields.map(f => `${f.name}: ${f.tsType};`).join('\n ');
names['listHeaders'] = fields.map(f => `<th>${f.name}</th>`).join('\n ');
names['listCells'] = fields.map(f => `<td>{{ item.${f.name} }}</td>`).join('\n ');
names['detailsRows'] = fields.map(f => `<tr>\n <th>${f.name}</th>\n <td>{{ ${names.camel}.${f.name} }}</td>\n </tr>`).join('\n ');
const formFields = fields.filter(f => f.name !== 'id');
names['formControls'] = formFields.map(f => `${f.name}: [null]`).join(',\n ');
names['formFields'] = formFields.map(f => this.getFormFieldHtml(f)).join('\n\n ');
const filterableFields = fields.filter(f => f.tsType === 'string' && f.name !== 'id');
names['filterFormControls'] = filterableFields.map(f => `${f.name}: ['']`).join(',\n ');
names['filterFormFields'] = filterableFields.map(f => this.getFormFieldHtml(f, true)).join('\n\n ');
const numericFields = fields.filter(c => c.tsType === 'number' && c.name !== 'id').map(c => c.name);
names['numericFieldsArray'] = JSON.stringify(numericFields);
names['colspan'] = (fields.length + 1).toString();
const config = this.configService.get();
const adminRoot = path.resolve(process.cwd(), config.admin.path);
const featureDir = path.join(adminRoot, 'src', 'app', 'features', names.plural);
try {
await this.generateModel(names, featureDir);
await this.generateService(names, featureDir);
@ -52,64 +73,55 @@ export class AngularGeneratorService {
await this.generateDetailsComponent(names, featureDir);
await this.generateFormComponent(names, featureDir);
const listComponentPath = path.join(
featureDir,
'components',
`${names.singular}-list`,
`${names.singular}-list.component.ts`,
);
const detailsComponentPath = path.join(
featureDir, 'components', `${names.singular}-details`, `${names.singular}-details.component.ts`
);
const listCompPath = path.join(featureDir, 'components', `${names.singular}-list`, `${names.singular}-list.component.ts`);
const detailsCompPath = path.join(featureDir, 'components', `${names.singular}-details`, `${names.singular}-details.component.ts`);
const formCompPath = path.join(featureDir, 'components', `${names.singular}-form`, `${names.singular}-form.component.ts`);
await this.moduleUpdaterService.addRouteToAngularApp(`${names.pascal}FormComponent`, formCompPath, `${names.plural}/new`);
await this.moduleUpdaterService.addRouteToAngularApp(`${names.pascal}FormComponent`, formCompPath, `${names.plural}/:id/edit`);
await this.moduleUpdaterService.addRouteToAngularApp(
`${names.pascal}DetailsComponent`,
detailsComponentPath,
`${names.plural}/:id`
);
await this.moduleUpdaterService.addRouteToAngularApp(
`${names.pascal}ListComponent`,
listComponentPath,
names.plural,
);
await this.moduleUpdaterService.addRouteToAngularApp(`${names.pascal}DetailsComponent`, detailsCompPath, `${names.plural}/:id`);
await this.moduleUpdaterService.addRouteToAngularApp(`${names.pascal}ListComponent`, listCompPath, names.plural);
console.log(`✅ Angular files for "${tableName}" created successfully in: ${featureDir}`);
console.log('\n✨ app.routes.ts has been updated automatically with list and details routes! ✨');
console.log('\n✨ app.routes.ts has been updated with full CRUD routes! ✨');
} catch (error) {
console.error(`❌ An error occurred during Angular generation:`, error.message);
}
}
private async generateFormComponent(names: NamingConvention, featureDir: string) {
const compDir = path.join(featureDir, 'components', `${names.singular}-form`);
const tsContent = this.templateService.render('angular/form.component.ts.tpl', names);
const htmlContent = this.templateService.render('angular/form.component.html.tpl', names);
private getFormFieldHtml(field: FieldDefinition, isFilter: boolean = false): string {
const label = `<label class="label"><span class="label-text">${field.name}</span></label>`;
const placeholder = isFilter ? `placeholder="Filter by ${field.name}"` : '';
let input = '';
await fsPromises.mkdir(compDir, { recursive: true });
fs.writeFileSync(path.join(compDir, `${names.singular}-form.component.ts`), tsContent);
fs.writeFileSync(path.join(compDir, `${names.singular}-form.component.html`), htmlContent);
switch (field.tsType) {
case 'boolean':
input = `<label class="label cursor-pointer justify-start gap-4">
<span class="label-text">${field.name}</span>
<input type="checkbox" formControlName="${field.name}" class="checkbox" />
</label>`;
return `<div class="form-control">${input}</div>`;
case 'number':
input = `<input type="number" formControlName="${field.name}" class="input input-bordered w-full" ${placeholder} />`;
break;
default: // string, Date, etc.
input = `<input type="text" formControlName="${field.name}" class="input input-bordered w-full" ${placeholder} />`;
}
private async generateDetailsComponent(names: NamingConvention, featureDir: string) {
const compDir = path.join(featureDir, 'components', `${names.singular}-details`);
const tsContent = this.templateService.render('angular/details.component.ts.tpl', names);
const htmlContent = this.templateService.render('angular/details.component.html.tpl', names);
await fsPromises.mkdir(compDir, { recursive: true });
fs.writeFileSync(path.join(compDir, `${names.singular}-details.component.ts`), tsContent);
fs.writeFileSync(path.join(compDir, `${names.singular}-details.component.html`), htmlContent);
return `<div class="form-control">${label}\n ${input}</div>`;
}
private mapDbToTsType(dbType: string): string {
if (dbType.includes('int') || dbType.includes('serial')) return 'number';
if (['float', 'double', 'decimal', 'numeric', 'real'].includes(dbType)) return 'number';
if (dbType.includes('char') || dbType.includes('text') || dbType === 'uuid') return 'string';
if (dbType === 'boolean' || dbType === 'bool') return 'boolean';
if (dbType.includes('date') || dbType.includes('time')) return 'Date';
return 'any';
}
// ... (All other generate... and naming methods are unchanged)
private async generateModel(names: NamingConvention, featureDir: string) {
const modelsDir = path.join(featureDir, 'models');
const content = this.templateService.render('angular/model.ts.tpl', names);
@ -117,7 +129,6 @@ export class AngularGeneratorService {
await fsPromises.mkdir(modelsDir, { recursive: true });
fs.writeFileSync(filePath, content);
}
private async generateService(names: NamingConvention, featureDir: string) {
const servicesDir = path.join(featureDir, 'services');
const content = this.templateService.render('angular/service.ts.tpl', names);
@ -125,27 +136,38 @@ export class AngularGeneratorService {
await fsPromises.mkdir(servicesDir, { recursive: true });
fs.writeFileSync(filePath, content);
}
private async generateFilterComponent(names: NamingConvention, featureDir: string) {
const compDir = path.join(featureDir, 'components', `${names.singular}-filter`);
const tsContent = this.templateService.render('angular/filter.component.ts.tpl', names);
const htmlContent = this.templateService.render('angular/filter.component.html.tpl', names);
await fsPromises.mkdir(compDir, { recursive: true });
fs.writeFileSync(path.join(compDir, `${names.singular}-filter.component.ts`), tsContent);
fs.writeFileSync(path.join(compDir, `${names.singular}-filter.component.html`), htmlContent);
}
private async generateListComponent(names: NamingConvention, featureDir: string) {
const compDir = path.join(featureDir, 'components', `${names.singular}-list`);
const tsContent = this.templateService.render('angular/list.component.ts.tpl', names);
const htmlContent = this.templateService.render('angular/list.component.html.tpl', names);
await fsPromises.mkdir(compDir, { recursive: true });
fs.writeFileSync(path.join(compDir, `${names.singular}-list.component.ts`), tsContent);
fs.writeFileSync(path.join(compDir, `${names.singular}-list.component.html`), htmlContent);
}
private async generateDetailsComponent(names: NamingConvention, featureDir: string) {
const compDir = path.join(featureDir, 'components', `${names.singular}-details`);
const tsContent = this.templateService.render('angular/details.component.ts.tpl', names);
const htmlContent = this.templateService.render('angular/details.component.html.tpl', names);
await fsPromises.mkdir(compDir, { recursive: true });
fs.writeFileSync(path.join(compDir, `${names.singular}-details.component.ts`), tsContent);
fs.writeFileSync(path.join(compDir, `${names.singular}-details.component.html`), htmlContent);
}
private async generateFormComponent(names: NamingConvention, featureDir: string) {
const compDir = path.join(featureDir, 'components', `${names.singular}-form`);
const tsContent = this.templateService.render('angular/form.component.ts.tpl', names);
const htmlContent = this.templateService.render('angular/form.component.html.tpl', names);
await fsPromises.mkdir(compDir, { recursive: true });
fs.writeFileSync(path.join(compDir, `${names.singular}-form.component.ts`), tsContent);
fs.writeFileSync(path.join(compDir, `${names.singular}-form.component.html`), htmlContent);
}
private getNamingConvention(tableName: string): NamingConvention {
const singular = this.toSingular(tableName);
const pascal = this.toPascalCase(singular);
@ -158,8 +180,6 @@ export class AngularGeneratorService {
kebab: singular,
};
}
// ... (rest of the naming utility functions are unchanged)
private toSingular(name: string): string {
if (name.endsWith('ies')) return name.slice(0, -3) + 'y';
return name.endsWith('s') ? name.slice(0, -1) : name;
@ -171,13 +191,4 @@ export class AngularGeneratorService {
const pascal = this.toPascalCase(text);
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
}
private mapDbToTsType(dbType: string): string {
if (dbType.includes('int') || dbType.includes('serial')) return 'number';
if (['float', 'double', 'decimal', 'numeric', 'real'].includes(dbType)) return 'number';
if (dbType.includes('char') || dbType.includes('text') || dbType === 'uuid') return 'string';
if (dbType === 'boolean' || dbType === 'bool') return 'boolean';
if (dbType.includes('date') || dbType.includes('time')) return 'Date';
return 'any';
}
}

View File

@ -7,21 +7,10 @@
<div class="card-body">
<h2 class="card-title text-3xl">{{title}} Details</h2>
<!-- Details List -->
<div class="overflow-x-auto mt-4">
<table class="table w-full">
<tbody>
<!-- Row for ID -->
<tr>
<th class="w-1/3">ID</th>
<td>{{ {{camel}}.id }}</td>
</tr>
<!-- Row for Name -->
<tr>
<th>Name</th>
<td>{{ {{camel}}.name }}</td>
</tr>
<!-- Add more rows for other properties here -->
{{detailsRows}}
</tbody>
</table>
</div>

View File

@ -1,18 +1,9 @@
<!-- dvbooking-cli/src/templates/angular/filter.component.html.tpl -->
<!-- Generated by the CLI -->
<form [formGroup]="filterForm" class="p-4 bg-base-200 rounded-lg shadow">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
{{filterFormFields}}
<!-- Filter for 'name' -->
<div class="form-control">
<label class="label">
<span class="label-text">Name</span>
</label>
<input type="text" formControlName="name" placeholder="Filter by name" class="input input-bordered w-full">
</div>
<!-- Add other filter inputs here -->
<!-- Reset Button -->
<div class="form-control">
<button type="button" (click)="reset()" class="btn btn-secondary">Reset</button>
</div>

View File

@ -1,3 +1,5 @@
// dvbooking-cli/src/templates/angular/filter.component.ts.tpl
// Generated by the CLI
import { Component, EventEmitter, Output } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
@ -15,15 +17,13 @@ export class {{pascal}}FilterComponent {
constructor(private fb: FormBuilder) {
this.filterForm = this.fb.group({
name: [''],
// Add other filterable form controls here
{{filterFormControls}}
});
this.filterForm.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged()
).subscribe(values => {
// Remove empty properties before emitting
const cleanFilter = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v != null && v !== '')
);

View File

@ -10,34 +10,7 @@
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="space-y-4 mt-4">
<!-- Name Field -->
<div class="form-control">
<label class="label">
<span class="label-text">Name</span>
</label>
<input type="text" formControlName="name" class="input input-bordered w-full" />
<div *ngIf="form.get('name')?.invalid && form.get('name')?.touched" class="text-error text-sm mt-1">
Name is required.
</div>
</div>
<!-- Price Field -->
<div class="form-control">
<label class="label">
<span class="label-text">Price</span>
</label>
<input type="number" formControlName="price" class="input input-bordered w-full" />
</div>
<!-- Is Available Checkbox -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<span class="label-text">Is Available?</span>
<input type="checkbox" formControlName="is_available" class="checkbox" />
</label>
</div>
<!-- Add other form fields here -->
{{formFields}}
<div class="card-actions justify-end mt-6">
<a routerLink="/{{plural}}" class="btn btn-ghost">Cancel</a>

View File

@ -30,10 +30,7 @@ export class {{pascal}}FormComponent implements OnInit {
private {{camel}}Service: {{pascal}}Service
) {
this.form = this.fb.group({
// Add your form controls here. Match them to your model/entity.
name: ['', Validators.required],
price: [null],
is_available: [true],
{{formControls}}
});
}
@ -60,6 +57,7 @@ export class {{pascal}}FormComponent implements OnInit {
onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}

View File

@ -1,27 +1,26 @@
<!-- dvbooking-cli/src/templates/angular/list.component.html.tpl -->
<!-- Generated by the CLI -->
<div class="p-4 md:p-8 space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">{{title}}s</h1>
<a routerLink="/{{plural}}/new" class="btn btn-primary">Create New</a>
<!-- Filter Component -->
</div>
<app-{{kebab}}-filter (filterChanged)="onFilterChanged($event)"></app-{{kebab}}-filter>
<!-- Data Table -->
<ng-container *ngIf="paginatedResponse$ | async as response; else loading">
<div class="overflow-x-auto bg-base-100 rounded-lg shadow">
<table class="table w-full">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<!-- Add other table headers here -->
{{listHeaders}}
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of response.data" class="hover">
<td>{{ item.id }}</td>
<td>{{ item.name }}</td>
<!-- Add other table data cells here -->
{{listCells}}
<td class="text-right space-x-2">
<a [routerLink]="['/{{plural}}', item.id]" class="btn btn-sm btn-ghost">View</a>
<a [routerLink]="['/{{plural}}', item.id, 'edit']" class="btn btn-sm btn-ghost">Edit</a>
@ -29,7 +28,7 @@
</td>
</tr>
<tr *ngIf="response.data.length === 0">
<td colspan="3" class="text-center">No {{plural}} found.</td>
<td colspan="{{colspan}}" class="text-center">No {{plural}} found.</td>
</tr>
</tbody>
</table>

View File

@ -1,8 +1,8 @@
// dvbooking-cli/src/templates/angular/model.ts.tpl
// Generated by the CLI
export interface {{pascal}} {
id: number;
name: string;
// Add other properties from your NestJS entity here
{{modelProperties}}
}
export interface PaginatedResponse<T> {