diff --git a/src/app.module.ts b/src/app.module.ts index 056eef5..adb178a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,6 +7,7 @@ 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'; +import { GenericTableGeneratorService } from './services/generic-table-generator.service'; @Module({ imports: [], @@ -17,7 +18,8 @@ import { TemplateService } from './services/template.service'; CrudGeneratorService, ModuleUpdaterService, AngularGeneratorService, - TemplateService + TemplateService, + GenericTableGeneratorService ], }) export class AppModule {} diff --git a/src/commands/generate.command.ts b/src/commands/generate.command.ts index dceac0e..f355d77 100644 --- a/src/commands/generate.command.ts +++ b/src/commands/generate.command.ts @@ -2,7 +2,7 @@ import { Command, CommandRunner } from 'nest-commander'; import { EntityGeneratorService } from '../services/entity-generator.service'; import { CrudGeneratorService } from '../services/crud-generator.service'; -import { AngularGeneratorService } from '../services/angular-generator.service'; // <-- 1. Import +import { AngularGeneratorService } from '../services/angular-generator.service'; @Command({ name: 'generate', @@ -52,7 +52,7 @@ export class GenerateCommand extends CommandRunner { } console.log('\n--- Generating CRUD Module ---'); - await this.crudGeneratorService.generate(name); + await this.crudGeneratorService.generate(name, columns); console.log('\n✨ All backend files generated successfully! ✨'); console.log('\n--- Generating Angular Files ---'); await this.angularGeneratorService.generate(name,columns); diff --git a/src/services/angular-generator.service.ts b/src/services/angular-generator.service.ts index 6ab6d2c..6e8df5c 100644 --- a/src/services/angular-generator.service.ts +++ b/src/services/angular-generator.service.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import * as fs from 'fs'; import { promises as fsPromises } from 'fs'; import { TableColumn } from 'typeorm'; +import { GenericTableGeneratorService } from './generic-table-generator.service'; // Interface for structured field metadata passed to templates interface FieldDefinition { @@ -31,6 +32,7 @@ export class AngularGeneratorService { private readonly configService: ConfigService, private readonly templateService: TemplateService, private readonly moduleUpdaterService: ModuleUpdaterService, + private readonly genericTableGeneratorService: GenericTableGeneratorService, ) {} public async generate(tableName: string, columns?: TableColumn[]): Promise { @@ -70,6 +72,11 @@ export class AngularGeneratorService { await this.generateService(names, featureDir); await this.generateFilterComponent(names, featureDir); await this.generateListComponent(names, featureDir); + + // 3. New Generic Table View + const tableCompPath = await this.generateTableComponent(tableName, columns || [], names, featureDir); + + // 4. Details & Form Views await this.generateDetailsComponent(names, featureDir); await this.generateFormComponent(names, featureDir); @@ -80,16 +87,23 @@ export class AngularGeneratorService { 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}DetailsComponent`, detailsCompPath, `${names.plural}/:id`); + + await this.moduleUpdaterService.addRouteToAngularApp(`${names.pascal}TableComponent`, tableCompPath, `${names.plural}/table`); await this.moduleUpdaterService.addRouteToAngularApp(`${names.pascal}ListComponent`, listCompPath, names.plural); console.log(`✅ Angular files for "${tableName}" created successfully in: ${featureDir}`); - console.log('\n✨ app.routes.ts has been updated with full CRUD routes! ✨'); + console.log('\n✨ app.routes.ts has been updated with full CRUD routes (List + Table)! ✨'); } catch (error) { console.error(`❌ An error occurred during Angular generation:`, error.message); } } +private async generateTableComponent(tableName: string, columns: TableColumn[], names: NamingConvention, featureDir: string): Promise { + // This method now correctly calls the renamed service + return this.genericTableGeneratorService.generate(tableName, columns, names, featureDir); + } + private getFormFieldHtml(field: FieldDefinition, isFilter: boolean = false): string { const label = ``; const placeholder = isFilter ? `placeholder="Filter by ${field.name}"` : ''; diff --git a/src/services/crud-generator.service.ts b/src/services/crud-generator.service.ts index fce24ec..161514e 100644 --- a/src/services/crud-generator.service.ts +++ b/src/services/crud-generator.service.ts @@ -5,12 +5,14 @@ import * as path from 'path'; import * as fs from 'fs'; import { promises as fsPromises } from 'fs'; import { ModuleUpdaterService } from './module-updater.service'; +import { TableColumn } from 'typeorm'; interface NamingConvention { singular: string; plural: string; pascal: string; camel: string; + searchableFields } @Injectable() @@ -18,13 +20,15 @@ export class CrudGeneratorService { constructor(private readonly configService: ConfigService, private readonly moduleUpdaterService: ModuleUpdaterService,) { } - public async generate(tableName: string): Promise { + public async generate(tableName: string, columns?: TableColumn[]): Promise { console.log(`Generating CRUD module for table: ${tableName}...`); - const names = this.getNamingConvention(tableName); + const names = this.getNamingConvention(tableName,columns); const config = this.configService.get(); const serverRoot = path.resolve(process.cwd(), config.server.path); const moduleDir = path.join(serverRoot, 'src', names.singular); + + try { await this.generateModuleFile(names, moduleDir); await this.generateControllerFile(names, moduleDir); @@ -47,7 +51,10 @@ export class CrudGeneratorService { } } - // --- MISSING METHODS NOW INCLUDED --- + private isStringBasedType(dbType: string): boolean { + return dbType.includes('char') || dbType.includes('text') || dbType === 'uuid'; + } + private async generateModuleFile(names: NamingConvention, moduleDir: string) { const template = this.getModuleTemplate(names); const filePath = path.join(moduleDir, `${names.plural}.module.ts`); @@ -120,7 +127,7 @@ export class Query${names.pascal}Dto { } private getControllerTemplate(names: NamingConvention): string { - return `import { Controller, Get, Post, Body, Patch, Param, Delete, Query, ParseIntPipe } from '@nestjs/common'; + return `import { Controller, Get, Post, Body, Patch, Param, Delete, Query, ParseIntPipe, DefaultValuePipe } from '@nestjs/common'; import { ${names.pascal}sService } from './${names.plural}.service'; import { Create${names.pascal}Dto } from './dto/create-${names.singular}.dto'; import { Update${names.pascal}Dto } from './dto/update-${names.singular}.dto'; @@ -139,12 +146,23 @@ export class ${names.pascal}sController { findAll(@Query() queryParams: Query${names.pascal}Dto) { return this.${names.camel}sService.findAll(queryParams); } + + @Get('search') + search( + @Query('q') term: string, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ) { + return this.${names.camel}sService.search(term, { page, limit }); + } @Get(':id') findOne(@Param('id', ParseIntPipe) id: number) { return this.${names.camel}sService.findOne(id); } + + @Patch(':id') update(@Param('id', ParseIntPipe) id: number, @Body() update${names.pascal}Dto: Update${names.pascal}Dto) { return this.${names.camel}sService.update(id, update${names.pascal}Dto); @@ -183,6 +201,10 @@ export class ${names.pascal}sService { @InjectRepository(${names.pascal}) private readonly ${names.camel}Repository: Repository<${names.pascal}>, ) {} + + private readonly searchableFields: (keyof ${names.pascal})[] = [ + ${names.searchableFields} + ]; create(create${names.pascal}Dto: Create${names.pascal}Dto) { const newRecord = this.${names.camel}Repository.create(create${names.pascal}Dto); @@ -249,6 +271,36 @@ export class ${names.pascal}sService { }; } + + async search(term: string, options: { page: number; limit: number }) { + + if (this.searchableFields.length === 0) { + console.warn('Search is not configured for this entity. Please populate the searchableFields array in the service.'); + return { data: [], meta: { totalItems: 0, itemCount: 0, itemsPerPage: options.limit, totalPages: 0, currentPage: options.page } }; + } + + const whereConditions = this.searchableFields.map(field => ({ + [field]: ILike('%'+term+'%'), + })); + + const [data, totalItems] = await this.${names.camel}Repository.findAndCount({ + where: whereConditions, + skip: (options.page - 1) * options.limit, + take: options.limit, + }); + + return { + data, + meta: { + totalItems, + itemCount: data.length, + itemsPerPage: options.limit, + totalPages: Math.ceil(totalItems / options.limit), + currentPage: options.page, + }, + }; + } + async findOne(id: number) { const record = await this.${names.camel}Repository.findOneBy({ id: id as any }); if (!record) { @@ -288,13 +340,17 @@ export class ${names.pascal}sService { return `import { PartialType } from '@nestjs/mapped-types';\nimport { Create${names.pascal}Dto } from './create-${names.singular}.dto';\n\nexport class Update${names.pascal}Dto extends PartialType(Create${names.pascal}Dto) {}\n`; } - private getNamingConvention(tableName: string): NamingConvention { + private getNamingConvention(tableName: string, columns?: TableColumn[]): NamingConvention { const singular = this.toSingular(tableName); + const searchableFields = (columns || []) + .filter(c => this.isStringBasedType(c.type)) + .map(c => `'${c.name}'`); // Wrap in quotes for the array return { singular: singular, plural: tableName, pascal: this.toPascalCase(singular), camel: this.toCamelCase(singular), + searchableFields }; } diff --git a/src/services/generic-table-generator.service.ts b/src/services/generic-table-generator.service.ts new file mode 100644 index 0000000..3b1a269 --- /dev/null +++ b/src/services/generic-table-generator.service.ts @@ -0,0 +1,86 @@ +// src/services/generic-list-generator.service.ts +import { Injectable } from '@nestjs/common'; +import { TemplateService } from './template.service'; +import { TableColumn } from 'typeorm'; +import * as path from 'path'; +import * as fs from 'fs'; +import { promises as fsPromises } from 'fs'; + +interface FieldDefinition { name: string; tsType: string; } +interface NamingConvention { [key: string]: string; /*...*/ } + +@Injectable() +export class GenericTableGeneratorService { + constructor(private readonly templateService: TemplateService) {} + + public async generate( + tableName: string, + columns: TableColumn[], + names: NamingConvention, + featureDir: string, + ): Promise { + console.log(`Generating Generic Table View for ${tableName}...`); + + const fields: FieldDefinition[] = columns.map(c => ({ + name: c.name, + tsType: this.mapDbToTsType(c.type), + })); + + names['columnDefinitions'] = this.buildColumnDefinitions(fields); + + // Create folder: components/product-table + const compDir = path.join(featureDir, 'components', `${names.singular}-table`); + await fsPromises.mkdir(compDir, { recursive: true }); + + // Generate Data Provider + fs.writeFileSync( + path.join(compDir, `${names.singular}-data-provider.service.ts`), + this.templateService.render('angular-generic/data-provider.service.ts.tpl', names), + ); + + // Generate Table Component TS + fs.writeFileSync( + path.join(compDir, `${names.singular}-table.component.ts`), + this.templateService.render('angular-generic/table.component.ts.tpl', names), + ); + + // Generate Table Component HTML + fs.writeFileSync( + path.join(compDir, `${names.singular}-table.component.html`), + this.templateService.render('angular-generic/table.component.html.tpl', names), + ); + + return path.join(compDir, `${names.singular}-table.component.ts`); + } + + private buildColumnDefinitions(fields: FieldDefinition[]): string { + return fields + .filter(f => f.name !== 'id') + .map(field => { + if (field.tsType === 'boolean') { + return ` { + attribute: '${field.name}', + headerCell: true, + valueCell: { + value: item => (item as any)?.${field.name} ? 'yes' : 'no', + }, + },`; + } + return ` { + attribute: '${field.name}', + headerCell: true, + valueCell: true, + },`; + }) + .join('\n'); + } + + 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'; + } +} \ No newline at end of file diff --git a/src/templates/angular-generic/data-provider.service.ts.tpl b/src/templates/angular-generic/data-provider.service.ts.tpl new file mode 100644 index 0000000..4c0a1aa --- /dev/null +++ b/src/templates/angular-generic/data-provider.service.ts.tpl @@ -0,0 +1,25 @@ +// dvbooking-cli/src/templates/angular-generic/data-provider.service.ts.tpl + +// Generated by the CLI +import { inject, Injectable } from '@angular/core'; +import { DataProvider, GetDataOptions, GetDataResponse } from '../../../../components/generic-table/data-provider.interface'; +import { {{pascal}} } from '../../models/{{singular}}.model'; +import { map, Observable } from 'rxjs'; +import { {{pascal}}Service } from '../../services/{{singular}}.service'; + +@Injectable({ + providedIn: 'root', +}) +export class {{pascal}}DataProvider implements DataProvider<{{pascal}}> { + private {{camel}}Service = inject({{pascal}}Service); + + getData(options?: GetDataOptions): Observable> { + // The generic table's params are compatible with our NestJS Query DTO + return this.{{camel}}Service.find(options?.params ?? {}).pipe( + map((res) => { + // Adapt the paginated response to the GetDataResponse format + return { data: res }; + }) + ); + } +} \ No newline at end of file diff --git a/src/templates/angular-generic/table.component.html.tpl b/src/templates/angular-generic/table.component.html.tpl new file mode 100644 index 0000000..81ff9b1 --- /dev/null +++ b/src/templates/angular-generic/table.component.html.tpl @@ -0,0 +1,11 @@ + + + +
+
+

{{title}}s (Generic Table)

+ Create New +
+ + +
\ No newline at end of file diff --git a/src/templates/angular-generic/table.component.ts.tpl b/src/templates/angular-generic/table.component.ts.tpl new file mode 100644 index 0000000..3adb103 --- /dev/null +++ b/src/templates/angular-generic/table.component.ts.tpl @@ -0,0 +1,96 @@ +// dvbooking-cli/src/templates/angular-generic/table.component.ts.tpl + +// Generated by the CLI +import { Component, inject, OnInit } from '@angular/core'; +import { Router, RouterModule } from '@angular/router'; +import { {{pascal}} } from '../../models/{{singular}}.model'; +import { {{pascal}}DataProvider } from './{{singular}}-data-provider.service'; +import { ColumnDefinition } from '../../../../components/generic-table/column-definition.interface'; +import { GenericTable } from '../../../../components/generic-table/generic-table'; +import { GenericTableConfig } from '../../../../components/generic-table/generic-table.config'; +import { + ActionDefinition, + GenericActionColumn, +} from '../../../../components/generic-action-column/generic-action-column'; +import { ProductService } from '../../services/{{singular}}.service'; +import { BehaviorSubject } from 'rxjs'; + +@Component({ + selector: 'app-{{kebab}}-table', + standalone: true, + imports: [GenericTable, RouterModule], + templateUrl: './{{singular}}-table.component.html', +}) +export class {{pascal}}TableComponent implements OnInit { + + private refresh$ = new BehaviorSubject(undefined); + private filter$ = new BehaviorSubject({}); + private page$ = new BehaviorSubject(1); + private limit$ = new BehaviorSubject(10); + + router = inject(Router); + tableConfig!: GenericTableConfig<{{pascal}}>; + + {{camel}}DataProvider = inject({{pascal}}DataProvider); + {{camel}}Service = inject({{pascal}}Service); + + ngOnInit(): void { + const actionHandler = (action: ActionDefinition<{{pascal}}>, item: {{pascal}}) => { + switch (action.action) { + case 'view': + this.router.navigate(['/{{plural}}', item?.id]); + break; + case 'edit': + this.router.navigate(['/{{plural}}', item?.id, 'edit']); + break; + case 'delete': + this.deleteItem(item.id); + break; + } + }; + + this.tableConfig = { + refresh$: this.refresh$, + filter$: this.filter$, + page$: this.page$, + limit$: this.limit$, + dataProvider: this.{{camel}}DataProvider, + columns: [ +{{columnDefinitions}} + { + attribute: 'actions', + headerCell: { value: 'Actions' }, + valueCell: { + component: GenericActionColumn, + componentInputs: item => ({ + item: item, + actions: [ + { action: 'view', handler: actionHandler }, + { action: 'edit', handler: actionHandler }, + { action: 'delete', handler: actionHandler }, + ] as ActionDefinition<{{pascal}}>[], + }), + }, + }, + ] as ColumnDefinition<{{pascal}}>[], + tableCssClass: '{{kebab}}-table-container', + }; + } + + 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/service.ts.tpl b/src/templates/angular/service.ts.tpl index 822b1eb..e201c36 100644 --- a/src/templates/angular/service.ts.tpl +++ b/src/templates/angular/service.ts.tpl @@ -7,6 +7,11 @@ import { Observable } from 'rxjs'; import { {{pascal}}, PaginatedResponse } from '../models/{{singular}}.model'; import { ConfigurationService } from '../../../services/configuration.service'; +export interface SearchResponse { + data: T[]; + total: number; +} + @Injectable({ providedIn: 'root' }) @@ -36,6 +41,18 @@ export class {{pascal}}Service { return this.http.get>(this.apiUrl, { params }); } + /** + * Search across multiple fields with a single term. + * @param term The search term (q). + */ + public search(term: string, page: number = 1, limit: number = 10): Observable> { + const params = new HttpParams() + .set('q', term) + .set('page', page.toString()) + .set('limit', limit.toString()); + return this.http.get>(`${this.apiUrl}/search`, { params }); + } + /** * Find a single record by its ID. */