From 8a5f9b76c0fd47552a881ea168f693dbcf0631a5 Mon Sep 17 00:00:00 2001 From: Roland Schneider Date: Tue, 18 Nov 2025 18:47:16 +0100 Subject: [PATCH] generate angular side --- src/app.module.ts | 11 +- src/commands/generate.command.ts | 13 +- src/services/angular-generator.service.ts | 357 ++++++++++++++++++++++ 3 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 src/services/angular-generator.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 674f423..c71fecd 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,14 +1,21 @@ import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; import { AppService } from './app.service'; import { GenerateCommand } from './commands/generate.command'; import { ConfigService } from './services/config.service'; import { EntityGeneratorService } from './services/entity-generator.service'; import { CrudGeneratorService } from './services/crud-generator.service'; import { ModuleUpdaterService } from './services/module-updater.service'; +import { AngularGeneratorService } from './services/angular-generator.service'; @Module({ imports: [], - providers: [AppService,GenerateCommand,ConfigService,EntityGeneratorService,CrudGeneratorService,ModuleUpdaterService], + providers: [AppService, + GenerateCommand, + ConfigService, + EntityGeneratorService, + CrudGeneratorService, + ModuleUpdaterService, + AngularGeneratorService + ], }) export class AppModule {} diff --git a/src/commands/generate.command.ts b/src/commands/generate.command.ts index f9611c2..b086a93 100644 --- a/src/commands/generate.command.ts +++ b/src/commands/generate.command.ts @@ -1,7 +1,8 @@ // src/commands/generate.command.ts import { Command, CommandRunner } from 'nest-commander'; 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({ name: 'generate', @@ -12,7 +13,8 @@ import { CrudGeneratorService } from '../services/crud-generator.service'; // <- export class GenerateCommand extends CommandRunner { constructor( private readonly entityGeneratorService: EntityGeneratorService, - private readonly crudGeneratorService: CrudGeneratorService, // <-- 2. Inject + private readonly crudGeneratorService: CrudGeneratorService, + private readonly angularGeneratorService: AngularGeneratorService ) { super(); } @@ -36,12 +38,19 @@ export class GenerateCommand extends CommandRunner { await this.crudGeneratorService.generate(name); break; + case 'angular': // <-- 3. Add new case + await this.angularGeneratorService.generate(name); + break; + case 'all': console.log('--- Generating Entity ---'); await this.entityGeneratorService.generate(name); console.log('\n--- Generating CRUD Module ---'); await this.crudGeneratorService.generate(name); 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; default: diff --git a/src/services/angular-generator.service.ts b/src/services/angular-generator.service.ts new file mode 100644 index 0000000..ffbf501 --- /dev/null +++ b/src/services/angular-generator.service.ts @@ -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 { + 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 { + 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): Observable> { + const params = new HttpParams({ fromObject: filter }); + return this.http.get>(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 { + 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(); + 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 ` +
+
+ + +
+ + +
+ + + + +
+ +
+
+
+`; + } + + 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(undefined); + private filter$ = new BehaviorSubject({}); + private page$ = new BehaviorSubject(1); + + paginatedResponse$!: Observable>; + + 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 ` +
+

${names.title}s

+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
IDNameActions
{{ item.id }}{{ item.name }} + + + +
No ${names.plural} found.
+
+ + +
+
+ + + +
+
+
+ + +
+ +
+
+
+`; + } + + // --- 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); + } +} \ No newline at end of file