generate angular side

This commit is contained in:
Roland Schneider 2025-11-18 18:47:16 +01:00
parent c5addf58d3
commit 8a5f9b76c0
3 changed files with 377 additions and 4 deletions

View File

@ -1,14 +1,21 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { GenerateCommand } from './commands/generate.command'; import { GenerateCommand } from './commands/generate.command';
import { ConfigService } from './services/config.service'; import { ConfigService } from './services/config.service';
import { EntityGeneratorService } from './services/entity-generator.service'; import { EntityGeneratorService } from './services/entity-generator.service';
import { CrudGeneratorService } from './services/crud-generator.service'; import { CrudGeneratorService } from './services/crud-generator.service';
import { ModuleUpdaterService } from './services/module-updater.service'; import { ModuleUpdaterService } from './services/module-updater.service';
import { AngularGeneratorService } from './services/angular-generator.service';
@Module({ @Module({
imports: [], imports: [],
providers: [AppService,GenerateCommand,ConfigService,EntityGeneratorService,CrudGeneratorService,ModuleUpdaterService], providers: [AppService,
GenerateCommand,
ConfigService,
EntityGeneratorService,
CrudGeneratorService,
ModuleUpdaterService,
AngularGeneratorService
],
}) })
export class AppModule {} export class AppModule {}

View File

@ -1,7 +1,8 @@
// src/commands/generate.command.ts // src/commands/generate.command.ts
import { Command, CommandRunner } from 'nest-commander'; import { Command, CommandRunner } from 'nest-commander';
import { EntityGeneratorService } from '../services/entity-generator.service'; import { EntityGeneratorService } from '../services/entity-generator.service';
import { CrudGeneratorService } from '../services/crud-generator.service'; // <-- 1. Import import { CrudGeneratorService } from '../services/crud-generator.service';
import { AngularGeneratorService } from '../services/angular-generator.service'; // <-- 1. Import
@Command({ @Command({
name: 'generate', name: 'generate',
@ -12,7 +13,8 @@ import { CrudGeneratorService } from '../services/crud-generator.service'; // <-
export class GenerateCommand extends CommandRunner { export class GenerateCommand extends CommandRunner {
constructor( constructor(
private readonly entityGeneratorService: EntityGeneratorService, private readonly entityGeneratorService: EntityGeneratorService,
private readonly crudGeneratorService: CrudGeneratorService, // <-- 2. Inject private readonly crudGeneratorService: CrudGeneratorService,
private readonly angularGeneratorService: AngularGeneratorService
) { ) {
super(); super();
} }
@ -36,12 +38,19 @@ export class GenerateCommand extends CommandRunner {
await this.crudGeneratorService.generate(name); await this.crudGeneratorService.generate(name);
break; break;
case 'angular': // <-- 3. Add new case
await this.angularGeneratorService.generate(name);
break;
case 'all': case 'all':
console.log('--- Generating Entity ---'); console.log('--- Generating Entity ---');
await this.entityGeneratorService.generate(name); await this.entityGeneratorService.generate(name);
console.log('\n--- Generating CRUD Module ---'); console.log('\n--- Generating CRUD Module ---');
await this.crudGeneratorService.generate(name); await this.crudGeneratorService.generate(name);
console.log('\n✨ All backend files generated successfully! ✨'); console.log('\n✨ All backend files generated successfully! ✨');
console.log('\n--- Generating Angular Files ---');
await this.angularGeneratorService.generate(name);
console.log('\n✨ All files generated successfully! ✨');
break; break;
default: default:

View File

@ -0,0 +1,357 @@
// src/services/angular-generator.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from './config.service';
import * as path from 'path';
import * as fs from 'fs';
import { promises as fsPromises } from 'fs';
interface NamingConvention {
singular: string; // product
plural: string; // products
pascal: string; // Product
camel: string; // product
title: string; // Product
kebab: string; // product
}
@Injectable()
export class AngularGeneratorService {
constructor(private readonly configService: ConfigService) {}
public async generate(tableName: string): Promise<void> {
console.log(`Generating Angular module for table: ${tableName}...`);
const names = this.getNamingConvention(tableName);
const config = this.configService.get();
const adminRoot = path.resolve(process.cwd(), config.admin.path);
// We'll place all generated files for a feature in one folder
const featureDir = path.join(adminRoot, 'src', 'app', 'features', names.plural);
try {
// Generate each part of the feature module
await this.generateModel(names, featureDir);
await this.generateService(names, featureDir);
await this.generateFilterComponent(names, featureDir);
await this.generateListComponent(names, featureDir);
// We will add the Details/Grid components later to keep this step focused
console.log(`✅ Angular files for "${tableName}" created successfully in: ${featureDir}`);
console.warn(`\n🔔 Action Required: Remember to create a route for ${names.pascal}ListComponent and declare it in a module!`);
} catch (error) {
console.error(`❌ An error occurred during Angular generation:`, error.message);
}
}
private async generateModel(names: NamingConvention, featureDir: string) {
const modelsDir = path.join(featureDir, 'models');
const template = this.getModelTemplate(names);
const filePath = path.join(modelsDir, `${names.singular}.model.ts`);
await fsPromises.mkdir(modelsDir, { recursive: true });
fs.writeFileSync(filePath, template);
}
private async generateService(names: NamingConvention, featureDir: string) {
const servicesDir = path.join(featureDir, 'services');
const template = this.getServiceTemplate(names);
const filePath = path.join(servicesDir, `${names.singular}.service.ts`);
await fsPromises.mkdir(servicesDir, { recursive: true });
fs.writeFileSync(filePath, template);
}
private async generateFilterComponent(names: NamingConvention, featureDir: string) {
const compDir = path.join(featureDir, 'components', `${names.singular}-filter`);
await fsPromises.mkdir(compDir, { recursive: true });
fs.writeFileSync(path.join(compDir, `${names.singular}-filter.component.ts`), this.getFilterComponentTsTemplate(names));
fs.writeFileSync(path.join(compDir, `${names.singular}-filter.component.html`), this.getFilterComponentHtmlTemplate(names));
}
private async generateListComponent(names: NamingConvention, featureDir: string) {
const compDir = path.join(featureDir, 'components', `${names.singular}-list`);
await fsPromises.mkdir(compDir, { recursive: true });
fs.writeFileSync(path.join(compDir, `${names.singular}-list.component.ts`), this.getListComponentTsTemplate(names));
fs.writeFileSync(path.join(compDir, `${names.singular}-list.component.html`), this.getListComponentHtmlTemplate(names));
}
// --- TEMPLATE METHODS ---
private getModelTemplate(names: NamingConvention): string {
return `// Generated by the CLI
// This is a placeholder model. Adjust properties based on your actual entity.
export interface ${names.pascal} {
id: number;
name: string;
// Add other properties from your NestJS entity here
}
export interface PaginatedResponse<T> {
data: T[];
meta: {
totalItems: number;
itemCount: number;
itemsPerPage: number;
totalPages: number;
currentPage: number;
};
}
`;
}
private getServiceTemplate(names: NamingConvention): string {
return `// Generated by the CLI
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ${names.pascal}, PaginatedResponse } from '../models/${names.singular}.model';
import { environment } from '../../../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class ${names.pascal}Service {
private apiUrl = \`\${environment.apiUrl}/${names.plural}\`;
constructor(private http: HttpClient) { }
// A type for the filter parameters. Should match the QueryDto in NestJS.
// For now, it's a flexible record.
public find(filter: Record<string, any>): Observable<PaginatedResponse<${names.pascal}>> {
const params = new HttpParams({ fromObject: filter });
return this.http.get<PaginatedResponse<${names.pascal}>>(this.apiUrl, { params });
}
public findOne(id: number): Observable<${names.pascal}> {
return this.http.get<${names.pascal}>(\`\${this.apiUrl}/\${id}\`);
}
public create(data: Omit<${names.pascal}, 'id'>): Observable<${names.pascal}> {
return this.http.post<${names.pascal}>(this.apiUrl, data);
}
public update(id: number, data: Partial<${names.pascal}>>): Observable<${names.pascal}> {
return this.http.patch<${names.pascal}>(\`\${this.apiUrl}/\${id}\`, data);
}
public remove(id: number): Observable<any> {
return this.http.delete(\`\${this.apiUrl}/\${id}\`);
}
}
`;
}
private getFilterComponentTsTemplate(names: NamingConvention): string {
return `// Generated by the CLI
import { Component, EventEmitter, Output } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
@Component({
selector: 'app-${names.kebab}-filter',
templateUrl: './${names.singular}-filter.component.html',
standalone: true,
imports: [ReactiveFormsModule]
})
export class ${names.pascal}FilterComponent {
@Output() filterChanged = new EventEmitter<any>();
filterForm: FormGroup;
constructor(private fb: FormBuilder) {
this.filterForm = this.fb.group({
name: [''],
// Add other filterable form controls here
});
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 !== '')
);
this.filterChanged.emit(cleanFilter);
});
}
reset() {
this.filterForm.reset();
}
}
`;
}
private getFilterComponentHtmlTemplate(names: NamingConvention): string {
return `<!-- 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">
<!-- 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>
</div>
</form>
`;
}
private getListComponentTsTemplate(names: NamingConvention): string {
return `// Generated by the CLI
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BehaviorSubject, Observable, combineLatest } from 'rxjs';
import { switchMap, map, startWith } from 'rxjs/operators';
import { ${names.pascal}, PaginatedResponse } from '../../models/${names.singular}.model';
import { ${names.pascal}Service } from '../../services/${names.singular}.service';
import { ${names.pascal}FilterComponent } from '../${names.singular}-filter/${names.singular}-filter.component';
@Component({
selector: 'app-${names.kebab}-list',
templateUrl: './${names.singular}-list.component.html',
standalone: true,
imports: [CommonModule, ${names.pascal}FilterComponent],
})
export class ${names.pascal}ListComponent implements OnInit {
private refresh$ = new BehaviorSubject<void>(undefined);
private filter$ = new BehaviorSubject<any>({});
private page$ = new BehaviorSubject<number>(1);
paginatedResponse$!: Observable<PaginatedResponse<${names.pascal}>>;
constructor(private ${names.camel}Service: ${names.pascal}Service) { }
ngOnInit(): void {
this.paginatedResponse$ = combineLatest([
this.refresh$,
this.filter$.pipe(startWith({})),
this.page$.pipe(startWith(1))
]).pipe(
switchMap(([_, filter, page]) => {
const query = { ...filter, page, limit: 10 };
return this.${names.camel}Service.find(query);
})
);
}
onFilterChanged(filter: any) {
this.page$.next(1); // Reset to first page on filter change
this.filter$.next(filter);
}
changePage(newPage: number) {
if (newPage > 0) {
this.page$.next(newPage);
}
}
// Placeholder for delete action
deleteItem(id: number) {
if (confirm('Are you sure you want to delete this item?')) {
this.${names.camel}Service.remove(id).subscribe(() => {
console.log(\`Item \${id} deleted\`);
this.refresh$.next(); // Trigger a data refresh
});
}
}
}
`;
}
private getListComponentHtmlTemplate(names: NamingConvention): string {
return `<!-- Generated by the CLI -->
<div class="p-4 md:p-8 space-y-6">
<h1 class="text-3xl font-bold">${names.title}s</h1>
<!-- Filter Component -->
<app-${names.kebab}-filter (filterChanged)="onFilterChanged($event)"></app-${names.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 -->
<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 -->
<td class="text-right space-x-2">
<button class="btn btn-sm btn-ghost">View</button>
<button class="btn btn-sm btn-ghost">Edit</button>
<button (click)="deleteItem(item.id)" class="btn btn-sm btn-error btn-ghost">Delete</button>
</td>
</tr>
<tr *ngIf="response.data.length === 0">
<td colspan="3" class="text-center">No ${names.plural} found.</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination Controls -->
<div *ngIf="response.meta.totalPages > 1" class="flex justify-center mt-4">
<div class="join">
<button
class="join-item btn"
(click)="changePage(response.meta.currentPage - 1)"
[disabled]="response.meta.currentPage === 1">«</button>
<button class="join-item btn">Page {{ response.meta.currentPage }} of {{ response.meta.totalPages }}</button>
<button
class="join-item btn"
(click)="changePage(response.meta.currentPage + 1)"
[disabled]="response.meta.currentPage === response.meta.totalPages">»</button>
</div>
</div>
</ng-container>
<ng-template #loading>
<div class="text-center p-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
</ng-template>
</div>
`;
}
// --- Naming Utilities ---
private getNamingConvention(tableName: string): NamingConvention {
const singular = this.toSingular(tableName);
const pascal = this.toPascalCase(singular);
return {
singular: singular,
plural: tableName,
pascal: pascal,
camel: this.toCamelCase(singular),
title: pascal,
kebab: singular,
};
}
private toSingular(name: string): string {
if (name.endsWith('ies')) return name.slice(0, -3) + 'y';
return name.endsWith('s') ? name.slice(0, -1) : name;
}
private toPascalCase(text: string): string {
return text.replace(/(^\w|-\w|_w)/g, (c) => c.replace(/[-_]/, '').toUpperCase());
}
private toCamelCase(text: string): string {
const pascal = this.toPascalCase(text);
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
}
}