// 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

ID Name Actions
{{ 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); } }