generate angular list component
This commit is contained in:
parent
8a5f9b76c0
commit
27829fc7ad
@ -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 {}
|
||||
|
||||
@ -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<string, string>
|
||||
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<void> {
|
||||
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<T> {
|
||||
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<string, any>): Observable<PaginatedResponse<${names.pascal}>> {
|
||||
const params = new HttpParams({ fromObject: filter });
|
||||
return this.http.get<PaginatedResponse<${names.pascal}>>(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<any> {
|
||||
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<any>();
|
||||
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 `<!-- Generated by the CLI -->
|
||||
<form [formGroup]="filterForm" class="p-4 bg-base-200 rounded-lg shadow">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
||||
|
||||
<!-- Filter for 'name' -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Name</span>
|
||||
</label>
|
||||
<input type="text" formControlName="name" placeholder="Filter by name" class="input input-bordered w-full">
|
||||
</div>
|
||||
|
||||
<!-- Add other filter inputs here -->
|
||||
|
||||
<!-- Reset Button -->
|
||||
<div class="form-control">
|
||||
<button type="button" (click)="reset()" class="btn btn-secondary">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
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<void>(undefined);
|
||||
private filter$ = new BehaviorSubject<any>({});
|
||||
private page$ = new BehaviorSubject<number>(1);
|
||||
|
||||
paginatedResponse$!: Observable<PaginatedResponse<${names.pascal}>>;
|
||||
|
||||
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 `<!-- Generated by the CLI -->
|
||||
<div class="p-4 md:p-8 space-y-6">
|
||||
<h1 class="text-3xl font-bold">${names.title}s</h1>
|
||||
|
||||
<!-- Filter Component -->
|
||||
<app-${names.kebab}-filter (filterChanged)="onFilterChanged($event)"></app-${names.kebab}-filter>
|
||||
|
||||
<!-- Data Table -->
|
||||
<ng-container *ngIf="paginatedResponse$ | async as response; else loading">
|
||||
<div class="overflow-x-auto bg-base-100 rounded-lg shadow">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<!-- Add other table headers here -->
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let item of response.data" class="hover">
|
||||
<td>{{ item.id }}</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<!-- Add other table data cells here -->
|
||||
<td class="text-right space-x-2">
|
||||
<button class="btn btn-sm btn-ghost">View</button>
|
||||
<button class="btn btn-sm btn-ghost">Edit</button>
|
||||
<button (click)="deleteItem(item.id)" class="btn btn-sm btn-error btn-ghost">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="response.data.length === 0">
|
||||
<td colspan="3" class="text-center">No ${names.plural} found.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div *ngIf="response.meta.totalPages > 1" class="flex justify-center mt-4">
|
||||
<div class="join">
|
||||
<button
|
||||
class="join-item btn"
|
||||
(click)="changePage(response.meta.currentPage - 1)"
|
||||
[disabled]="response.meta.currentPage === 1">«</button>
|
||||
<button class="join-item btn">Page {{ response.meta.currentPage }} of {{ response.meta.totalPages }}</button>
|
||||
<button
|
||||
class="join-item btn"
|
||||
(click)="changePage(response.meta.currentPage + 1)"
|
||||
[disabled]="response.meta.currentPage === response.meta.totalPages">»</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #loading>
|
||||
<div class="text-center p-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
|
||||
@ -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<void> {
|
||||
// ... (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<void> {
|
||||
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<void> {
|
||||
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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/services/template.service.ts
Normal file
26
src/services/template.service.ts
Normal file
@ -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, string>): 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/templates/angular/filter.component.html.tpl
Normal file
20
src/templates/angular/filter.component.html.tpl
Normal file
@ -0,0 +1,20 @@
|
||||
<!-- Generated by the CLI -->
|
||||
<form [formGroup]="filterForm" class="p-4 bg-base-200 rounded-lg shadow">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
||||
|
||||
<!-- Filter for 'name' -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Name</span>
|
||||
</label>
|
||||
<input type="text" formControlName="name" placeholder="Filter by name" class="input input-bordered w-full">
|
||||
</div>
|
||||
|
||||
<!-- Add other filter inputs here -->
|
||||
|
||||
<!-- Reset Button -->
|
||||
<div class="form-control">
|
||||
<button type="button" (click)="reset()" class="btn btn-secondary">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
37
src/templates/angular/filter.component.ts.tpl
Normal file
37
src/templates/angular/filter.component.ts.tpl
Normal file
@ -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<any>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
59
src/templates/angular/list.component.html.tpl
Normal file
59
src/templates/angular/list.component.html.tpl
Normal file
@ -0,0 +1,59 @@
|
||||
<!-- Generated by the CLI -->
|
||||
<div class="p-4 md:p-8 space-y-6">
|
||||
<h1 class="text-3xl font-bold">{{title}}s</h1>
|
||||
|
||||
<!-- Filter Component -->
|
||||
<app-{{kebab}}-filter (filterChanged)="onFilterChanged($event)"></app-{{kebab}}-filter>
|
||||
|
||||
<!-- Data Table -->
|
||||
<ng-container *ngIf="paginatedResponse$ | async as response; else loading">
|
||||
<div class="overflow-x-auto bg-base-100 rounded-lg shadow">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<!-- Add other table headers here -->
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let item of response.data" class="hover">
|
||||
<td>{{ item.id }}</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<!-- Add other table data cells here -->
|
||||
<td class="text-right space-x-2">
|
||||
<button class="btn btn-sm btn-ghost">View</button>
|
||||
<button class="btn btn-sm btn-ghost">Edit</button>
|
||||
<button (click)="deleteItem(item.id)" class="btn btn-sm btn-error btn-ghost">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="response.data.length === 0">
|
||||
<td colspan="3" class="text-center">No {{plural}} found.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div *ngIf="response.meta.totalPages > 1" class="flex justify-center mt-4">
|
||||
<div class="join">
|
||||
<button
|
||||
class="join-item btn"
|
||||
(click)="changePage(response.meta.currentPage - 1)"
|
||||
[disabled]="response.meta.currentPage === 1">«</button>
|
||||
<button class="join-item btn">Page {{ response.meta.currentPage }} of {{ response.meta.totalPages }}</button>
|
||||
<button
|
||||
class="join-item btn"
|
||||
(click)="changePage(response.meta.currentPage + 1)"
|
||||
[disabled]="response.meta.currentPage === response.meta.totalPages">»</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #loading>
|
||||
<div class="text-center p-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
67
src/templates/angular/list.component.ts.tpl
Normal file
67
src/templates/angular/list.component.ts.tpl
Normal file
@ -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<void>(undefined);
|
||||
private filter$ = new BehaviorSubject<any>({});
|
||||
private page$ = new BehaviorSubject<number>(1);
|
||||
|
||||
paginatedResponse$!: Observable<PaginatedResponse<{{pascal}}>>;
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/templates/angular/model.ts.tpl
Normal file
17
src/templates/angular/model.ts.tpl
Normal file
@ -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<T> {
|
||||
data: T[];
|
||||
meta: {
|
||||
totalItems: number;
|
||||
itemCount: number;
|
||||
itemsPerPage: number;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
};
|
||||
}
|
||||
66
src/templates/angular/service.ts.tpl
Normal file
66
src/templates/angular/service.ts.tpl
Normal file
@ -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<string, any>): Observable<PaginatedResponse<{{pascal}}>> {
|
||||
// --- 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<PaginatedResponse<{{pascal}}>>(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<Omit<{{pascal}}, 'id'>>): Observable<{{pascal}}> {
|
||||
return this.http.patch<{{pascal}}>(`${this.apiUrl}/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a record by its ID.
|
||||
*/
|
||||
public remove(id: number): Observable<any> {
|
||||
return this.http.delete(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user