add generic table
This commit is contained in:
parent
a6d495545a
commit
0a26f8a28e
@ -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 {}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}"` : '';
|
||||
|
||||
@ -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';
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
86
src/services/generic-table-generator.service.ts
Normal file
86
src/services/generic-table-generator.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
25
src/templates/angular-generic/data-provider.service.ts.tpl
Normal file
25
src/templates/angular-generic/data-provider.service.ts.tpl
Normal 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 };
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
11
src/templates/angular-generic/table.component.html.tpl
Normal file
11
src/templates/angular-generic/table.component.html.tpl
Normal 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>
|
||||
96
src/templates/angular-generic/table.component.ts.tpl
Normal file
96
src/templates/angular-generic/table.component.ts.tpl
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user