diff --git a/src/app.module.ts b/src/app.module.ts index c71fecd..056eef5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ 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'; +import { TemplateService } from './services/template.service'; @Module({ imports: [], @@ -15,7 +16,8 @@ import { AngularGeneratorService } from './services/angular-generator.service'; EntityGeneratorService, CrudGeneratorService, ModuleUpdaterService, - AngularGeneratorService + AngularGeneratorService, + TemplateService ], }) export class AppModule {} diff --git a/src/services/angular-generator.service.ts b/src/services/angular-generator.service.ts index ffbf501..59d8430 100644 --- a/src/services/angular-generator.service.ts +++ b/src/services/angular-generator.service.ts @@ -1,41 +1,58 @@ // src/services/angular-generator.service.ts import { Injectable } from '@nestjs/common'; import { ConfigService } from './config.service'; +import { TemplateService } from './template.service'; +import { ModuleUpdaterService } from './module-updater.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 + [key: string]: string; // This index signature makes it compatible with Record + singular: string; + plural: string; + pascal: string; + camel: string; + title: string; + kebab: string; } @Injectable() export class AngularGeneratorService { - constructor(private readonly configService: ConfigService) {} + constructor( + private readonly configService: ConfigService, + private readonly templateService: TemplateService, + private readonly moduleUpdaterService: ModuleUpdaterService, + ) {} 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 + + const listComponentPath = path.join( + featureDir, + 'components', + `${names.singular}-list`, + `${names.singular}-list.component.ts`, + ); + + await this.moduleUpdaterService.addRouteToAngularApp( + `${names.pascal}ListComponent`, + listComponentPath, + names.plural, + ); 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!`); + console.log('\n✨ app.routes.ts has been updated automatically! ✨'); } catch (error) { console.error(`❌ An error occurred during Angular generation:`, error.message); @@ -44,293 +61,40 @@ export class AngularGeneratorService { private async generateModel(names: NamingConvention, featureDir: string) { const modelsDir = path.join(featureDir, 'models'); - const template = this.getModelTemplate(names); + const content = this.templateService.render('angular/model.ts.tpl', names); const filePath = path.join(modelsDir, `${names.singular}.model.ts`); await fsPromises.mkdir(modelsDir, { recursive: true }); - fs.writeFileSync(filePath, template); + fs.writeFileSync(filePath, content); } private async generateService(names: NamingConvention, featureDir: string) { const servicesDir = path.join(featureDir, 'services'); - const template = this.getServiceTemplate(names); + const content = this.templateService.render('angular/service.ts.tpl', names); const filePath = path.join(servicesDir, `${names.singular}.service.ts`); await fsPromises.mkdir(servicesDir, { recursive: true }); - fs.writeFileSync(filePath, template); + 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`), this.getFilterComponentTsTemplate(names)); - fs.writeFileSync(path.join(compDir, `${names.singular}-filter.component.html`), this.getFilterComponentHtmlTemplate(names)); + 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`), this.getListComponentTsTemplate(names)); - fs.writeFileSync(path.join(compDir, `${names.singular}-list.component.html`), this.getListComponentHtmlTemplate(names)); + fs.writeFileSync(path.join(compDir, `${names.singular}-list.component.ts`), tsContent); + fs.writeFileSync(path.join(compDir, `${names.singular}-list.component.html`), htmlContent); } - - // --- 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); @@ -343,6 +107,8 @@ export class ${names.pascal}ListComponent implements OnInit { 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; diff --git a/src/services/module-updater.service.ts b/src/services/module-updater.service.ts index f9b1e3e..4b7d0ec 100644 --- a/src/services/module-updater.service.ts +++ b/src/services/module-updater.service.ts @@ -1,7 +1,7 @@ // src/services/module-updater.service.ts import { Injectable } from '@nestjs/common'; import { ConfigService } from './config.service'; -import { Project, PropertyAssignment, SyntaxKind, ObjectLiteralExpression, ArrayLiteralExpression, ArrowFunction } from 'ts-morph'; +import { Project, PropertyAssignment, SyntaxKind, ObjectLiteralExpression } from 'ts-morph'; import * as path from 'path'; @Injectable() @@ -9,7 +9,6 @@ export class ModuleUpdaterService { constructor(private readonly configService: ConfigService) {} public async addImportToAppModule(moduleNameToAdd: string, modulePath: string): Promise { - // ... (this method remains unchanged) const config = this.configService.get(); const serverRoot = path.resolve(process.cwd(), config.server.path); const appModulePath = path.join(serverRoot, 'src', 'app.module.ts'); @@ -56,7 +55,6 @@ export class ModuleUpdaterService { console.log('AppModule saved successfully.'); } - // --- NEW METHOD --- public async addEntityToTypeOrm(entityNameToAdd: string, entityPath: string): Promise { const config = this.configService.get(); const serverRoot = path.resolve(process.cwd(), config.server.path); @@ -112,4 +110,63 @@ export class ModuleUpdaterService { await sourceFile.save(); console.log('AppModule saved successfully after entity registration.'); } + + public async addRouteToAngularApp( + componentNameToAdd: string, + componentPath: string, + routeName: string, + ): Promise { + const config = this.configService.get(); + const adminRoot = path.resolve(process.cwd(), config.admin.path); + const appRoutesPath = path.join(adminRoot, 'src', 'app', 'app.routes.ts'); + + console.log(`Attempting to update ${appRoutesPath} for new route...`); + + try { + const project = new Project(); + const sourceFile = project.addSourceFileAtPath(appRoutesPath); + + const relativeComponentPath = path + .relative(path.dirname(appRoutesPath), componentPath) + .replace(/\\/g, '/') + .replace('.ts', ''); + + const existingImport = sourceFile.getImportDeclaration( + (d) => d.getModuleSpecifierValue() === `./${relativeComponentPath}`, + ); + + if (!existingImport) { + sourceFile.addImportDeclaration({ + namedImports: [componentNameToAdd], + moduleSpecifier: `./${relativeComponentPath}`, + }); + console.log(`Added import for component ${componentNameToAdd}.`); + } else { + console.log(`Import for component ${componentNameToAdd} already exists.`); + } + + const routesDeclaration = sourceFile.getVariableDeclarationOrThrow('routes'); + const routesArray = routesDeclaration.getInitializerIfKindOrThrow( + SyntaxKind.ArrayLiteralExpression, + ); + + const routeAlreadyExists = routesArray + .getElements() + .some((elem) => elem.getText().includes(`path: '${routeName}'`)); + + if (!routeAlreadyExists) { + routesArray.insertElement(0, `{ path: '${routeName}', component: ${componentNameToAdd} }`); + console.log(`Added route for path '${routeName}' to the beginning of the routes array.`); + } else { + console.log(`Route for path '${routeName}' already exists.`); + } + + await sourceFile.save(); + console.log('app.routes.ts saved successfully.'); + + } catch (error) { + console.error(`❌ Failed to update app.routes.ts:`, error.message); + console.warn('You may need to add the route manually.'); + } + } } \ No newline at end of file diff --git a/src/services/template.service.ts b/src/services/template.service.ts new file mode 100644 index 0000000..b96e370 --- /dev/null +++ b/src/services/template.service.ts @@ -0,0 +1,26 @@ +// src/services/template.service.ts +import { Injectable } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; + +@Injectable() +export class TemplateService { + public render(templatePath: string, names: Record): string { + const fullPath = path.join(__dirname, '..', 'templates', templatePath); + + try { + let templateContent = fs.readFileSync(fullPath, 'utf8'); + + // Replace all {{key}} placeholders with values from the 'names' object + for (const key in names) { + const regex = new RegExp(`{{${key}}}`, 'g'); + templateContent = templateContent.replace(regex, names[key]); + } + + return templateContent; + } catch (error) { + console.error(`Error reading or rendering template: ${fullPath}`); + throw error; + } + } +} \ No newline at end of file diff --git a/src/templates/angular/filter.component.html.tpl b/src/templates/angular/filter.component.html.tpl new file mode 100644 index 0000000..7041668 --- /dev/null +++ b/src/templates/angular/filter.component.html.tpl @@ -0,0 +1,20 @@ + +
+
+ + +
+ + +
+ + + + +
+ +
+
+
\ No newline at end of file diff --git a/src/templates/angular/filter.component.ts.tpl b/src/templates/angular/filter.component.ts.tpl new file mode 100644 index 0000000..72923b4 --- /dev/null +++ b/src/templates/angular/filter.component.ts.tpl @@ -0,0 +1,37 @@ +// 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-{{kebab}}-filter', + templateUrl: './{{singular}}-filter.component.html', + standalone: true, + imports: [ReactiveFormsModule] +}) +export class {{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(); + } +} \ No newline at end of file diff --git a/src/templates/angular/list.component.html.tpl b/src/templates/angular/list.component.html.tpl new file mode 100644 index 0000000..e92d0ad --- /dev/null +++ b/src/templates/angular/list.component.html.tpl @@ -0,0 +1,59 @@ + +
+

{{title}}s

+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
IDNameActions
{{ item.id }}{{ item.name }} + + + +
No {{plural}} found.
+
+ + +
+
+ + + +
+
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/src/templates/angular/list.component.ts.tpl b/src/templates/angular/list.component.ts.tpl new file mode 100644 index 0000000..c01b265 --- /dev/null +++ b/src/templates/angular/list.component.ts.tpl @@ -0,0 +1,67 @@ +// dvbooking-cli/src/templates/angular/list.component.ts.tpl + +// Generated by the CLI +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { BehaviorSubject, Observable, combineLatest } from 'rxjs'; +import { switchMap, startWith } from 'rxjs/operators'; +import { {{pascal}}, PaginatedResponse } from '../../models/{{singular}}.model'; +import { {{pascal}}Service } from '../../services/{{singular}}.service'; +import { {{pascal}}FilterComponent } from '../{{singular}}-filter/{{singular}}-filter.component'; + +@Component({ + selector: 'app-{{kebab}}-list', + templateUrl: './{{singular}}-list.component.html', + standalone: true, + imports: [CommonModule, {{pascal}}FilterComponent], +}) +export class {{pascal}}ListComponent implements OnInit { + + private refresh$ = new BehaviorSubject(undefined); + private filter$ = new BehaviorSubject({}); + private page$ = new BehaviorSubject(1); + + paginatedResponse$!: Observable>; + + constructor(private {{camel}}Service: {{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.{{camel}}Service.find(query); + }) + ); + } + + onFilterChanged(filter: any): void { + this.page$.next(1); + this.filter$.next(filter); + } + + changePage(newPage: number): void { + if (newPage > 0) { + this.page$.next(newPage); + } + } + + deleteItem(id: number): void { + if (confirm('Are you sure you want to delete this item?')) { + this.{{camel}}Service.remove(id).subscribe({ + next: () => { + console.log(`Item with ID ${id} deleted successfully.`); + this.refresh$.next(); + }, + // --- THIS IS THE FIX --- + // Explicitly type 'err' to satisfy strict TypeScript rules. + error: (err: any) => { + console.error(`Error deleting item with ID ${id}:`, err); + } + }); + } + } +} \ No newline at end of file diff --git a/src/templates/angular/model.ts.tpl b/src/templates/angular/model.ts.tpl new file mode 100644 index 0000000..0ee4916 --- /dev/null +++ b/src/templates/angular/model.ts.tpl @@ -0,0 +1,17 @@ +// Generated by the CLI +export interface {{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; + }; +} \ No newline at end of file diff --git a/src/templates/angular/service.ts.tpl b/src/templates/angular/service.ts.tpl new file mode 100644 index 0000000..822b1eb --- /dev/null +++ b/src/templates/angular/service.ts.tpl @@ -0,0 +1,66 @@ +// dvbooking-cli/src/templates/angular/service.ts.tpl + +// Generated by the CLI +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { {{pascal}}, PaginatedResponse } from '../models/{{singular}}.model'; +import { ConfigurationService } from '../../../services/configuration.service'; + +@Injectable({ + providedIn: 'root' +}) +export class {{pascal}}Service { + private readonly apiUrl: string; + + constructor( + private http: HttpClient, + private configService: ConfigurationService + ) { + this.apiUrl = `${this.configService.getApiUrl()}/{{plural}}`; + } + + /** + * Find records with pagination and filtering. + */ + public find(filter: Record): Observable> { + // --- THIS IS THE FIX --- + // The incorrect line: .filter(([_, v]) for v != null) + // is now correctly written with an arrow function. + const cleanFilter = Object.fromEntries( + Object.entries(filter).filter(([_, v]) => v != null) + ); + // --- END OF FIX --- + + const params = new HttpParams({ fromObject: cleanFilter }); + return this.http.get>(this.apiUrl, { params }); + } + + /** + * Find a single record by its ID. + */ + public findOne(id: number): Observable<{{pascal}}> { + return this.http.get<{{pascal}}>(`${this.apiUrl}/${id}`); + } + + /** + * Create a new record. + */ + public create(data: Omit<{{pascal}}, 'id'>): Observable<{{pascal}}> { + return this.http.post<{{pascal}}>(this.apiUrl, data); + } + + /** + * Update an existing record. + */ + public update(id: number, data: Partial>): Observable<{{pascal}}> { + return this.http.patch<{{pascal}}>(`${this.apiUrl}/${id}`, data); + } + + /** + * Remove a record by its ID. + */ + public remove(id: number): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } +} \ No newline at end of file