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 { promises as fsPromises } from 'fs';
import { TableColumn } from 'typeorm'; 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 { interface NamingConvention {
[key: string]: string; // This index signature makes it compatible with Record<string, string> [key: string]: string;
singular: string; singular: string;
plural: string; plural: string;
pascal: string; pascal: string;
@ -30,20 +37,34 @@ export class AngularGeneratorService {
console.log(`Generating Angular module for table: ${tableName}...`); console.log(`Generating Angular module for table: ${tableName}...`);
const names = this.getNamingConvention(tableName); const names = this.getNamingConvention(tableName);
const numericFields = (columns || []) const fields: FieldDefinition[] = (columns || [])
.filter(c => { .map(c => ({
const tsType = this.mapDbToTsType(c.type); name: c.name,
return tsType === 'number' && !c.isPrimary; tsType: this.mapDbToTsType(c.type),
}) }));
.map(c => c.name);
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['numericFieldsArray'] = JSON.stringify(numericFields);
names['colspan'] = (fields.length + 1).toString();
const config = this.configService.get(); const config = this.configService.get();
const adminRoot = path.resolve(process.cwd(), config.admin.path); const adminRoot = path.resolve(process.cwd(), config.admin.path);
const featureDir = path.join(adminRoot, 'src', 'app', 'features', names.plural); const featureDir = path.join(adminRoot, 'src', 'app', 'features', names.plural);
try { try {
await this.generateModel(names, featureDir); await this.generateModel(names, featureDir);
await this.generateService(names, featureDir); await this.generateService(names, featureDir);
@ -52,64 +73,55 @@ export class AngularGeneratorService {
await this.generateDetailsComponent(names, featureDir); await this.generateDetailsComponent(names, featureDir);
await this.generateFormComponent(names, featureDir); await this.generateFormComponent(names, featureDir);
const listComponentPath = path.join( const listCompPath = path.join(featureDir, 'components', `${names.singular}-list`, `${names.singular}-list.component.ts`);
featureDir, const detailsCompPath = path.join(featureDir, 'components', `${names.singular}-details`, `${names.singular}-details.component.ts`);
'components',
`${names.singular}-list`,
`${names.singular}-list.component.ts`,
);
const detailsComponentPath = 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`); 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}/new`);
await this.moduleUpdaterService.addRouteToAngularApp(`${names.pascal}FormComponent`, formCompPath, `${names.plural}/:id/edit`); await this.moduleUpdaterService.addRouteToAngularApp(`${names.pascal}FormComponent`, formCompPath, `${names.plural}/:id/edit`);
await this.moduleUpdaterService.addRouteToAngularApp(`${names.pascal}DetailsComponent`, detailsCompPath, `${names.plural}/:id`);
await this.moduleUpdaterService.addRouteToAngularApp( await this.moduleUpdaterService.addRouteToAngularApp(`${names.pascal}ListComponent`, listCompPath, names.plural);
`${names.pascal}DetailsComponent`,
detailsComponentPath,
`${names.plural}/:id`
);
await this.moduleUpdaterService.addRouteToAngularApp(
`${names.pascal}ListComponent`,
listComponentPath,
names.plural,
);
console.log(`✅ Angular files for "${tableName}" created successfully in: ${featureDir}`); 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) { } catch (error) {
console.error(`❌ An error occurred during Angular generation:`, error.message); console.error(`❌ An error occurred during Angular generation:`, error.message);
} }
} }
private async generateFormComponent(names: NamingConvention, featureDir: string) { private getFormFieldHtml(field: FieldDefinition, isFilter: boolean = false): string {
const compDir = path.join(featureDir, 'components', `${names.singular}-form`); const label = `<label class="label"><span class="label-text">${field.name}</span></label>`;
const tsContent = this.templateService.render('angular/form.component.ts.tpl', names); const placeholder = isFilter ? `placeholder="Filter by ${field.name}"` : '';
const htmlContent = this.templateService.render('angular/form.component.html.tpl', names); let input = '';
await fsPromises.mkdir(compDir, { recursive: true }); switch (field.tsType) {
fs.writeFileSync(path.join(compDir, `${names.singular}-form.component.ts`), tsContent); case 'boolean':
fs.writeFileSync(path.join(compDir, `${names.singular}-form.component.html`), htmlContent); 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) { return `<div class="form-control">${label}\n ${input}</div>`;
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 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) { private async generateModel(names: NamingConvention, featureDir: string) {
const modelsDir = path.join(featureDir, 'models'); const modelsDir = path.join(featureDir, 'models');
const content = this.templateService.render('angular/model.ts.tpl', names); const content = this.templateService.render('angular/model.ts.tpl', names);
@ -117,7 +129,6 @@ export class AngularGeneratorService {
await fsPromises.mkdir(modelsDir, { recursive: true }); await fsPromises.mkdir(modelsDir, { recursive: true });
fs.writeFileSync(filePath, content); fs.writeFileSync(filePath, content);
} }
private async generateService(names: NamingConvention, featureDir: string) { private async generateService(names: NamingConvention, featureDir: string) {
const servicesDir = path.join(featureDir, 'services'); const servicesDir = path.join(featureDir, 'services');
const content = this.templateService.render('angular/service.ts.tpl', names); const content = this.templateService.render('angular/service.ts.tpl', names);
@ -125,27 +136,38 @@ export class AngularGeneratorService {
await fsPromises.mkdir(servicesDir, { recursive: true }); await fsPromises.mkdir(servicesDir, { recursive: true });
fs.writeFileSync(filePath, content); fs.writeFileSync(filePath, content);
} }
private async generateFilterComponent(names: NamingConvention, featureDir: string) { private async generateFilterComponent(names: NamingConvention, featureDir: string) {
const compDir = path.join(featureDir, 'components', `${names.singular}-filter`); const compDir = path.join(featureDir, 'components', `${names.singular}-filter`);
const tsContent = this.templateService.render('angular/filter.component.ts.tpl', names); const tsContent = this.templateService.render('angular/filter.component.ts.tpl', names);
const htmlContent = this.templateService.render('angular/filter.component.html.tpl', names); const htmlContent = this.templateService.render('angular/filter.component.html.tpl', names);
await fsPromises.mkdir(compDir, { recursive: true }); 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.ts`), tsContent);
fs.writeFileSync(path.join(compDir, `${names.singular}-filter.component.html`), htmlContent); fs.writeFileSync(path.join(compDir, `${names.singular}-filter.component.html`), htmlContent);
} }
private async generateListComponent(names: NamingConvention, featureDir: string) { private async generateListComponent(names: NamingConvention, featureDir: string) {
const compDir = path.join(featureDir, 'components', `${names.singular}-list`); const compDir = path.join(featureDir, 'components', `${names.singular}-list`);
const tsContent = this.templateService.render('angular/list.component.ts.tpl', names); const tsContent = this.templateService.render('angular/list.component.ts.tpl', names);
const htmlContent = this.templateService.render('angular/list.component.html.tpl', names); const htmlContent = this.templateService.render('angular/list.component.html.tpl', names);
await fsPromises.mkdir(compDir, { recursive: true }); 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.ts`), tsContent);
fs.writeFileSync(path.join(compDir, `${names.singular}-list.component.html`), htmlContent); 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 { private getNamingConvention(tableName: string): NamingConvention {
const singular = this.toSingular(tableName); const singular = this.toSingular(tableName);
const pascal = this.toPascalCase(singular); const pascal = this.toPascalCase(singular);
@ -158,8 +180,6 @@ export class AngularGeneratorService {
kebab: singular, kebab: singular,
}; };
} }
// ... (rest of the naming utility functions are unchanged)
private toSingular(name: string): string { private toSingular(name: string): string {
if (name.endsWith('ies')) return name.slice(0, -3) + 'y'; if (name.endsWith('ies')) return name.slice(0, -3) + 'y';
return name.endsWith('s') ? name.slice(0, -1) : name; return name.endsWith('s') ? name.slice(0, -1) : name;
@ -171,13 +191,4 @@ export class AngularGeneratorService {
const pascal = this.toPascalCase(text); const pascal = this.toPascalCase(text);
return pascal.charAt(0).toLowerCase() + pascal.slice(1); 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"> <div class="card-body">
<h2 class="card-title text-3xl">{{title}} Details</h2> <h2 class="card-title text-3xl">{{title}} Details</h2>
<!-- Details List -->
<div class="overflow-x-auto mt-4"> <div class="overflow-x-auto mt-4">
<table class="table w-full"> <table class="table w-full">
<tbody> <tbody>
<!-- Row for ID --> {{detailsRows}}
<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 -->
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -1,20 +1,11 @@
<!-- dvbooking-cli/src/templates/angular/filter.component.html.tpl -->
<!-- Generated by the CLI --> <!-- Generated by the CLI -->
<form [formGroup]="filterForm" class="p-4 bg-base-200 rounded-lg shadow"> <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"> <div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
{{filterFormFields}}
<!-- Filter for 'name' --> <div class="form-control">
<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> <button type="button" (click)="reset()" class="btn btn-secondary">Reset</button>
</div> </div>
</div> </div>
</form> </form>

View File

@ -1,3 +1,5 @@
// dvbooking-cli/src/templates/angular/filter.component.ts.tpl
// Generated by the CLI // Generated by the CLI
import { Component, EventEmitter, Output } from '@angular/core'; import { Component, EventEmitter, Output } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
@ -15,15 +17,13 @@ export class {{pascal}}FilterComponent {
constructor(private fb: FormBuilder) { constructor(private fb: FormBuilder) {
this.filterForm = this.fb.group({ this.filterForm = this.fb.group({
name: [''], {{filterFormControls}}
// Add other filterable form controls here
}); });
this.filterForm.valueChanges.pipe( this.filterForm.valueChanges.pipe(
debounceTime(300), debounceTime(300),
distinctUntilChanged() distinctUntilChanged()
).subscribe(values => { ).subscribe(values => {
// Remove empty properties before emitting
const cleanFilter = Object.fromEntries( const cleanFilter = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v != null && v !== '') 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"> <form [formGroup]="form" (ngSubmit)="onSubmit()" class="space-y-4 mt-4">
<!-- Name Field --> {{formFields}}
<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 -->
<div class="card-actions justify-end mt-6"> <div class="card-actions justify-end mt-6">
<a routerLink="/{{plural}}" class="btn btn-ghost">Cancel</a> <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 private {{camel}}Service: {{pascal}}Service
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
// Add your form controls here. Match them to your model/entity. {{formControls}}
name: ['', Validators.required],
price: [null],
is_available: [true],
}); });
} }
@ -60,6 +57,7 @@ export class {{pascal}}FormComponent implements OnInit {
onSubmit(): void { onSubmit(): void {
if (this.form.invalid) { if (this.form.invalid) {
this.form.markAllAsTouched();
return; return;
} }

View File

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

View File

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