diff --git a/admin/src/app/components/generic-action-column/generic-action-column.css b/admin/src/app/components/generic-action-column/generic-action-column.css new file mode 100644 index 0000000..e69de29 diff --git a/admin/src/app/components/generic-action-column/generic-action-column.html b/admin/src/app/components/generic-action-column/generic-action-column.html new file mode 100644 index 0000000..b889bf5 --- /dev/null +++ b/admin/src/app/components/generic-action-column/generic-action-column.html @@ -0,0 +1,3 @@ +@for (action of actions(); track action){ + {{action.action}} +} diff --git a/admin/src/app/components/generic-action-column/generic-action-column.ts b/admin/src/app/components/generic-action-column/generic-action-column.ts new file mode 100644 index 0000000..f73b58c --- /dev/null +++ b/admin/src/app/components/generic-action-column/generic-action-column.ts @@ -0,0 +1,38 @@ +import { Component, inject, input, OnInit } from '@angular/core'; +import { Router, RouterLink } from '@angular/router'; + +export interface ActionDefinition { + action: string; + generate: (action: string, item?: T) => string; + handler?: (action: ActionDefinition, item?: T) => void; +} + +@Component({ + selector: 'app-generic-action-column', + imports: [ + RouterLink, + ], + templateUrl: './generic-action-column.html', + styleUrl: './generic-action-column.css', + standalone: true, +}) +export class GenericActionColumn implements OnInit { + + private router = inject(Router); + + actions = input([] as ActionDefinition[]); + item = input(undefined as T); + payload = input(undefined as any); + + ngOnInit(): void { + } + + + onClick($event: any, actionDefinition: ActionDefinition) { + if (actionDefinition?.handler) { + actionDefinition.handler(actionDefinition, this.item()); + } + } + + +} diff --git a/admin/src/app/components/generic-table/cell-definition.interface.ts b/admin/src/app/components/generic-table/cell-definition.interface.ts new file mode 100644 index 0000000..2c729c2 --- /dev/null +++ b/admin/src/app/components/generic-table/cell-definition.interface.ts @@ -0,0 +1,7 @@ +import { Type } from '@angular/core'; + +export interface CellDefinition { + value?: ((item?: T) => any) | string; + component?: Type; + componentInputs?: (item?: T|null) => { [key: string]: any }; +} diff --git a/admin/src/app/components/generic-table/column-definition.interface.ts b/admin/src/app/components/generic-table/column-definition.interface.ts new file mode 100644 index 0000000..30e56e1 --- /dev/null +++ b/admin/src/app/components/generic-table/column-definition.interface.ts @@ -0,0 +1,15 @@ +import { CellDefinition } from './cell-definition.interface'; + +export interface TypeDefinition{ + type: 'boolean' | 'number' | 'string' | 'date' | 'time' | 'datetime'; + params?: Record; +} + + + +export interface ColumnDefinition { + attribute: keyof T; + type: TypeDefinition; + valueCell?: CellDefinition | boolean + headerCell?: CellDefinition | boolean +} diff --git a/admin/src/app/components/generic-table/data-provider.interface.ts b/admin/src/app/components/generic-table/data-provider.interface.ts new file mode 100644 index 0000000..b532908 --- /dev/null +++ b/admin/src/app/components/generic-table/data-provider.interface.ts @@ -0,0 +1,23 @@ +import { Observable } from 'rxjs'; +import { PaginatedResponse } from '../../features/products/models/product.model'; + + +export interface QueryParams extends Record{ + sortBy?: string; + sortDirection?: 'asc' | 'desc', + page?: number, + limit?: number, + q?: string, +} + +export interface GetDataOptions extends Record{ + params?: QueryParams +} + +export interface GetDataResponse extends Record{ + data: PaginatedResponse; +} + +export interface DataProvider { + getData( options?: GetDataOptions): Observable>; +} diff --git a/admin/src/app/components/generic-table/generic-table-search-form/generic-table-search-form.css b/admin/src/app/components/generic-table/generic-table-search-form/generic-table-search-form.css new file mode 100644 index 0000000..e69de29 diff --git a/admin/src/app/components/generic-table/generic-table-search-form/generic-table-search-form.html b/admin/src/app/components/generic-table/generic-table-search-form/generic-table-search-form.html new file mode 100644 index 0000000..5448258 --- /dev/null +++ b/admin/src/app/components/generic-table/generic-table-search-form/generic-table-search-form.html @@ -0,0 +1,6 @@ +
+
+
+
+
+
diff --git a/admin/src/app/components/generic-table/generic-table-search-form/generic-table-search-form.spec.ts b/admin/src/app/components/generic-table/generic-table-search-form/generic-table-search-form.spec.ts new file mode 100644 index 0000000..f1c202e --- /dev/null +++ b/admin/src/app/components/generic-table/generic-table-search-form/generic-table-search-form.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GenericTableSearchForm } from './generic-table-search-form'; + +describe('GenericTableSearchForm', () => { + let component: GenericTableSearchForm; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GenericTableSearchForm] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GenericTableSearchForm); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/admin/src/app/components/generic-table/generic-table-search-form/generic-table-search-form.ts b/admin/src/app/components/generic-table/generic-table-search-form/generic-table-search-form.ts new file mode 100644 index 0000000..37beb04 --- /dev/null +++ b/admin/src/app/components/generic-table/generic-table-search-form/generic-table-search-form.ts @@ -0,0 +1,41 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'; + +@Component({ + selector: 'app-generic-table-search-form', + imports: [ + FormsModule, + ReactiveFormsModule, + ], + templateUrl: './generic-table-search-form.html', + styleUrl: './generic-table-search-form.css', +}) +export class GenericTableSearchForm { + @Output() searchTermChanged = new EventEmitter(); + filterForm: FormGroup; + + constructor(private fb: FormBuilder) { + this.filterForm = this.fb.group({ + term: [''] + }); + + this.filterForm.valueChanges.pipe( + debounceTime(300), + filter( value => { + console.info(value) + return value.term && value.term.length >= 3; + }), + distinctUntilChanged() + ).subscribe(values => { + const cleanFilter = Object.fromEntries( + Object.entries(values).filter(([_, v]) => v != null && v !== '') + ); + this.searchTermChanged.emit(cleanFilter); + }); + } + + reset() { + this.filterForm.reset(); + } +} diff --git a/admin/src/app/components/generic-table/generic-table.config.ts b/admin/src/app/components/generic-table/generic-table.config.ts new file mode 100644 index 0000000..7256fa7 --- /dev/null +++ b/admin/src/app/components/generic-table/generic-table.config.ts @@ -0,0 +1,14 @@ +import { DataProvider } from './data-provider.interface'; +import { ColumnDefinition } from './column-definition.interface'; +import { Subject } from 'rxjs'; + +export interface GenericTableConfig { + dataProvider: DataProvider; + columns: ColumnDefinition[]; + tableCssClass?: string; + rowCssClass?: (item: T) => string; + refresh$: Subject; + filter$: Subject; + page$: Subject; + limit$: Subject; +} diff --git a/admin/src/app/components/generic-table/generic-table.html b/admin/src/app/components/generic-table/generic-table.html new file mode 100644 index 0000000..cb6a277 --- /dev/null +++ b/admin/src/app/components/generic-table/generic-table.html @@ -0,0 +1,72 @@ +
+ + @if (data$ | async; as getDataResponse) { + + + + + @for (column of config.columns; track column) { + + } + + + + @for (item of getDataResponse?.data?.data; track item) { + + + @for (column of config.columns; track column) { + + } + + } + + +
+ @if (column.headerCell) { + @if (typeof column.headerCell === 'boolean') { + {{ column.attribute }} + } @else { + + @if (!column?.headerCell?.component) { + @if (typeof column.headerCell.value === 'string') { +
+ } + } @else { + + } + } + } +
+ @if (column.valueCell) { + @if (typeof column.valueCell === 'boolean') { + {{ resolveValue(item, column) }} + } @else { + @if (!column.valueCell.component) { + {{ resolveValue(item, column, column.valueCell) }} + } @else { + + } + } + } +
+ @if ((getDataResponse?.data?.meta?.totalPages ?? 1) > 1) { +
+
+ + + +
+
+ } + } +
diff --git a/admin/src/app/components/generic-table/generic-table.ts b/admin/src/app/components/generic-table/generic-table.ts new file mode 100644 index 0000000..27ca733 --- /dev/null +++ b/admin/src/app/components/generic-table/generic-table.ts @@ -0,0 +1,91 @@ +import { Component, inject, Input, OnInit } from '@angular/core'; +import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs'; +import { ColumnDefinition } from './column-definition.interface'; +import { AsyncPipe, NgClass, NgComponentOutlet } from '@angular/common'; +import { CellDefinition } from './cell-definition.interface'; +import { GetDataResponse } from './data-provider.interface'; +import { DomSanitizer } from '@angular/platform-browser'; +import { GenericTableConfig } from './generic-table.config'; +import { startWith, switchMap } from 'rxjs/operators'; +import { GenericTableSearchForm } from './generic-table-search-form/generic-table-search-form'; + +@Component({ + selector: 'app-generic-table', + templateUrl: './generic-table.html', + imports: [NgClass, AsyncPipe, NgComponentOutlet, GenericTableSearchForm], + standalone: true, +}) +export class GenericTable implements OnInit { + + + + @Input() config!: GenericTableConfig; + public data$!: Observable>; + + sanitizer = inject(DomSanitizer); + parser = new DOMParser() + + ngOnInit(): void { + + this.data$ = combineLatest([ + this.config.refresh$, + this.config.filter$.pipe(startWith({})), + this.config.page$.pipe(startWith(1)), + this.config.limit$.pipe(startWith(10)) + ]).pipe( + switchMap(([_, filter, page, limit]) => { + const query = { ...filter, page, limit: 10 }; + console.info("filter is", filter) + return this.config.dataProvider.getData({ + params: { + q: filter.term ?? '', + page, + limit + } + }); + }) + ); + + } + + resolveValue(item: T, column: ColumnDefinition, cell?: CellDefinition): any { + if ( cell) { + if (cell.value) { + if (typeof cell.value === 'string') { + return cell.value; + } + if (typeof cell.value === 'function') { + return cell.value(item); + } + } + } + if (column.attribute) { + return item[column.attribute]; + } + return ''; + } + + getComponentInputs(item: T|null, column: ColumnDefinition, cell: CellDefinition): { [key: string]: any } { + if (cell.componentInputs) { + return cell.componentInputs(item); + } + return { data: item }; // Default input + } + + getAsHtml(str: string){ + // return this.sanitizer.bypassSecurityTrustHtml(str); + return this.sanitizer.bypassSecurityTrustHtml(this.parser.parseFromString(str, 'text/html').body.innerHTML); + } + + changePage(newPage: number): void { + if (newPage > 0) { + this.config.page$.next(newPage); + } + } + + protected searchTermChanged(searchTerm: any) { + console.info("searchterm",searchTerm); + this.config.page$.next(1); + this.config.filter$.next(searchTerm); + } +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 85aee95..ada175f 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -9,6 +9,8 @@ import { User } from './entity/user'; import { UserGroup } from './entity/user-group'; import { UserRole } from './entity/user-role'; import { LoggerModule } from './logger/logger.module'; +import { Product } from "./entity/product.entity"; +import { ProductsModule } from "./product/products.module"; const moduleTypeOrm = TypeOrmModule.forRootAsync({ imports: [ConfigModule], @@ -21,7 +23,7 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({ username: configService.get('DATABASE_USER'), password: configService.get('DATABASE_PASS'), database: configService.get('DATABASE_NAME'), - entities: [User, UserGroup, UserRole], + entities: [User, UserGroup, UserRole, Product], logging: true, // synchronize: true, }; @@ -35,7 +37,8 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({ UserModule, AuthModule, LoggerModule, - ], + ProductsModule + ], controllers: [AppController], providers: [AppService], })