add generic table

This commit is contained in:
Roland Schneider 2025-11-19 20:30:18 +01:00
parent a6d495545a
commit 0a26f8a28e
9 changed files with 316 additions and 9 deletions

View File

@ -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 {}

View File

@ -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);

View File

@ -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<void> {
@ -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<string> {
// 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 = `<label class="label"><span class="label-text">${field.name}</span></label>`;
const placeholder = isFilter ? `placeholder="Filter by ${field.name}"` : '';

View File

@ -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<void> {
public async generate(tableName: string, columns?: TableColumn[]): Promise<void> {
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';
@ -140,11 +147,22 @@ export class ${names.pascal}sController {
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);
@ -184,6 +202,10 @@ export class ${names.pascal}sService {
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);
return this.${names.camel}Repository.save(newRecord);
@ -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
};
}

View File

@ -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<string> {
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';
}
}

View File

@ -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<GetDataResponse<{{pascal}}>> {
// 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 };
})
);
}
}

View File

@ -0,0 +1,11 @@
<!-- dvbooking-cli/src/templates/angular-generic/table.component.html.tpl -->
<!-- Generated by the CLI -->
<div class="p-4 md:p-8 space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">{{title}}s (Generic Table)</h1>
<a routerLink="/{{plural}}/new" class="btn btn-primary">Create New</a>
</div>
<app-generic-table [config]="tableConfig"></app-generic-table>
</div>

View File

@ -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<void>(undefined);
private filter$ = new BehaviorSubject<any>({});
private page$ = new BehaviorSubject<number>(1);
private limit$ = new BehaviorSubject<number>(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);
}
});
}
}
}

View File

@ -7,6 +7,11 @@ import { Observable } from 'rxjs';
import { {{pascal}}, PaginatedResponse } from '../models/{{singular}}.model';
import { ConfigurationService } from '../../../services/configuration.service';
export interface SearchResponse<T> {
data: T[];
total: number;
}
@Injectable({
providedIn: 'root'
})
@ -36,6 +41,18 @@ export class {{pascal}}Service {
return this.http.get<PaginatedResponse<{{pascal}}>>(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<PaginatedResponse<{{pascal}}>> {
const params = new HttpParams()
.set('q', term)
.set('page', page.toString())
.set('limit', limit.toString());
return this.http.get<PaginatedResponse<{{pascal}}>>(`${this.apiUrl}/search`, { params });
}
/**
* Find a single record by its ID.
*/