Feature: Paged customer list.

This commit is contained in:
2025-07-10 09:39:55 +00:00
parent af684c976e
commit 0dbec4784b
9 changed files with 390 additions and 39 deletions

View File

@ -0,0 +1,17 @@
export class PagedResult<T> {
items: T[];
page: number; // current page (zero-based)
pageSize: number; // items per page
total: number; // total items in all pages
totalPages: number;
field?: string; // active sort column
sortDirection?: 'asc' | 'desc'; // sort direction
constructor(items: T[], page: number, pageSize: number, total: number) {
this.items = items;
this.page = page;
this.pageSize = pageSize;
this.total = total;
this.totalPages = Math.ceil(total / pageSize);
}
}

View File

@ -2,8 +2,14 @@ import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { Customer } from '../core/customer';
import { PagedResult } from '../core/paged-result';
import { CustomerService } from './customer.service';
export const customerListResolver: ResolveFn<Customer[]> = () => {
return inject(CustomerService).list();
export const customerListResolver: ResolveFn<PagedResult<Customer>> = (route) => {
const q = route.queryParamMap.get('q');
const page = Number(route.queryParamMap.get('p') ?? 0);
const size = Number(route.queryParamMap.get('s') ?? 50);
const field = route.queryParamMap.get('f') ?? 'name';
const direction = route.queryParamMap.get('d') ?? 'asc';
return inject(CustomerService).list(q, page, size, field, direction);
};

View File

@ -1,16 +1,161 @@
import { DataSource } from '@angular/cdk/collections';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { Observable, of as observableOf } from 'rxjs';
import { PagedResult } from 'src/app/core/paged-result';
import { Customer } from '../../core/customer';
export class CustomerListDatasource extends DataSource<Customer> {
constructor(public data: Customer[]) {
constructor(
public data: PagedResult<Customer>,
private paginator?: MatPaginator,
private sort?: MatSort,
) {
super();
}
connect(): Observable<Customer[]> {
return observableOf(this.data);
if (this.paginator) {
this.paginator.length = this.data.total;
if (this.data.pageSize !== undefined) {
this.paginator.pageSize = this.data.pageSize;
}
if (this.data.page !== undefined) {
this.paginator.pageIndex = this.data.page;
}
}
if (this.sort) {
if (this.data.field !== undefined) {
this.sort.active = this.data.field;
}
if (this.data.sortDirection !== undefined) {
this.sort.direction = this.data.sortDirection;
}
}
return observableOf(this.data.items);
}
disconnect() {}
}
// import { DataSource } from '@angular/cdk/collections';
// import { map, merge, Observable, of as observableOf, tap } from 'rxjs';
// import { Customer } from '../../core/customer';
// import { MatPaginator, PageEvent } from '@angular/material/paginator';
// import { MatSort, Sort } from '@angular/material/sort';
// import { EventEmitter } from '@angular/core';
// export class CustomerListDatasource extends DataSource<Customer> {
// private filterValue = '';
// constructor(public data: Customer[],
// private readonly filter: Observable<string>,
// private paginator?: MatPaginator,
// private sort?: MatSort,
// ) {
// super();
// this.filter = filter.pipe(
// tap((x) => {
// this.filterValue = x;
// }),
// );
// }
// connect(): Observable<Customer[]> {
// const dataMutations: (EventEmitter<PageEvent> | EventEmitter<Sort>)[] = [];
// if (this.paginator) {
// dataMutations.push((this.paginator as MatPaginator).page);
// }
// if (this.sort) {
// dataMutations.push((this.sort as MatSort).sortChange);
// }
// return merge(observableOf(this.data), this.filter, ...dataMutations).pipe(
// map(() => this.getFilteredData([...this.data])),
// tap((x: Customer[]) => {
// if (this.paginator) {
// this.paginator.length = x.length;
// }
// }),
// map((x: Customer[]) => this.getPagedData(this.getSortedData(x))),
// );
// }
// disconnect() {}
// private getFilteredData(data: Customer[]): Customer[] {
// return this.filterValue.toLowerCase().split(' ').reduce(
// (p: Customer[], c: string) =>
// p.filter((x) => {
// const productString = `${x.name} ${x.phone}${x.address}`.toLowerCase();
// return productString.indexOf(c) !== -1;
// }),
// Object.assign([], data),
// );
// }
// private getPagedData(data: Customer[]) {
// if (this.paginator === undefined) {
// return data;
// }
// const startIndex = this.paginator.pageIndex * this.paginator.pageSize;
// return data.splice(startIndex, this.paginator.pageSize);
// }
// private getSortedData(data: Customer[]) {
// if (this.sort === undefined) {
// return data;
// }
// if (!this.sort.active || this.sort.direction === '') {
// return data;
// }
// const sort = this.sort as MatSort;
// console.log('Sorting by', sort.active, sort.direction);
// return data.sort(sortBy(sort.active as keyof Customer, sort.direction === 'asc'));
// }
// }
// function compareValues<T>(a: T, b: T, isAsc: boolean): number {
// if (a === b) return 0;
// if (a == null) return isAsc ? -1 : 1;
// if (b == null) return isAsc ? 1 : -1;
// if (typeof a === "string" && typeof b === "string") {
// return isAsc ? a.localeCompare(b) : b.localeCompare(a);
// }
// if (typeof a === "number" && typeof b === "number") {
// return isAsc ? a - b : b - a;
// }
// if (typeof a === "boolean" && typeof b === "boolean") {
// return isAsc ? (Number(a) - Number(b)) : (Number(b) - Number(a));
// }
// // Fallback
// const aStr = String(a);
// const bStr = String(b);
// return isAsc ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
// }
// function sortBy<T, K extends keyof T>(
// key: K,
// isAsc = true
// ): (a: T, b: T) => number {
// return (a, b) => compareValues(a[key], b[key], isAsc);
// }
// // function getNestedValue<T>(obj: T, path: string | string[]): any {
// // const keys = Array.isArray(path) ? path : path.split(".");
// // return keys.reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
// // }
// // function sortByNested<T>(
// // path: string | string[],
// // isAsc = true
// // ): (a: T, b: T) => number {
// // return (a, b) => {
// // const aValue = getNestedValue(a, path);
// // const bValue = getNestedValue(b, path);
// // return compareValues(aValue, bValue, isAsc);
// // };
// // }

View File

@ -9,10 +9,18 @@
</mat-card-title-group>
</mat-card-header>
<mat-card-content>
<mat-table #table [dataSource]="dataSource" aria-label="Elements">
<form [formGroup]="form" class="flex flex-col">
<div class="flex flex-row justify-around content-start items-start">
<mat-form-field class="flex-auto">
<mat-label>Filter</mat-label>
<input type="text" matInput #filterElement formControlName="filter" autocomplete="off" />
</mat-form-field>
</div>
</form>
<mat-table #table [dataSource]="dataSource" matSort aria-label="Elements">
<!-- Name Column -->
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Name</mat-header-cell>
<mat-header-cell *matHeaderCellDef mat-sort-header>Name</mat-header-cell>
<mat-cell *matCellDef="let row"
><a [routerLink]="['/customers', row.id]">{{ row.name }}</a></mat-cell
>
@ -20,19 +28,19 @@
<!-- Phone Column -->
<ng-container matColumnDef="phone">
<mat-header-cell *matHeaderCellDef>Phone</mat-header-cell>
<mat-header-cell *matHeaderCellDef mat-sort-header>Phone</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.phone }}</mat-cell>
</ng-container>
<!-- Address Column -->
<ng-container matColumnDef="address">
<mat-header-cell *matHeaderCellDef>Address</mat-header-cell>
<mat-header-cell *matHeaderCellDef mat-sort-header>Address</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.address }}</mat-cell>
</ng-container>
<!-- Print In Bill Column -->
<ng-container matColumnDef="printInBill">
<mat-header-cell *matHeaderCellDef>Print in Bill</mat-header-cell>
<mat-header-cell *matHeaderCellDef mat-sort-header>Print in Bill</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.printInBill }}</mat-cell>
</ng-container>
@ -51,5 +59,13 @@
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>
<mat-paginator
#paginator
[length]="dataSource.data.total"
[pageIndex]="0"
[pageSize]="50"
[pageSizeOptions]="[25, 50, 100, 250, 5000]"
>
</mat-paginator>
</mat-card-content>
</mat-card>

View File

@ -1,10 +1,16 @@
import { PercentPipe } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject, AfterViewInit } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { debounceTime, distinctUntilChanged, Observable } from 'rxjs';
import { PagedResult } from 'src/app/core/paged-result';
import { Customer } from '../../core/customer';
import { CustomerListDatasource } from './customer-list-datasource';
@ -13,22 +19,88 @@ import { CustomerListDatasource } from './customer-list-datasource';
selector: 'app-customer-list',
templateUrl: './customer-list.component.html',
styleUrls: ['./customer-list.component.css'],
imports: [MatButtonModule, MatCardModule, MatIconModule, MatTableModule, PercentPipe, RouterLink],
imports: [
MatButtonModule,
MatCardModule,
MatIconModule,
MatTableModule,
PercentPipe,
RouterLink,
MatInputModule,
MatPaginatorModule,
MatSortModule,
ReactiveFormsModule,
],
})
export class CustomerListComponent implements OnInit {
export class CustomerListComponent implements OnInit, AfterViewInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private cdr = inject(ChangeDetectorRef);
@ViewChild('filterElement', { static: true }) filterElement!: ElementRef<HTMLInputElement>;
@ViewChild(MatPaginator, { static: true }) paginator!: MatPaginator;
@ViewChild(MatSort, { static: true }) sort!: MatSort;
data: PagedResult<Customer> = new PagedResult<Customer>([], 0, 0, 0);
filter: Observable<string>;
dataSource: CustomerListDatasource;
form: FormGroup<{
filter: FormControl<string>;
}>;
dataSource: CustomerListDatasource = new CustomerListDatasource([]);
list: Customer[] = [];
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
displayedColumns = ['name', 'phone', 'address', 'printInBill', 'discounts'];
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as { list: Customer[] };
data.list.forEach((c) => (c.discounts = c.discounts.filter((d) => d.discount !== 0)));
this.list = data.list;
constructor() {
this.form = new FormGroup({
filter: new FormControl<string>('', { nonNullable: true }),
});
this.dataSource = new CustomerListDatasource(this.list);
this.filter = this.form.controls.filter.valueChanges.pipe(debounceTime(150), distinctUntilChanged());
this.dataSource = new CustomerListDatasource(this.data, this.paginator, this.sort);
}
ngOnInit() {
const q = this.route.snapshot.queryParamMap.get('q');
if (q) {
this.form.controls.filter.setValue(q, { emitEvent: false }); // Prevent infinite loop
}
this.route.data.subscribe((value) => {
const data = value as { data: PagedResult<Customer> };
data.data.items.forEach((c) => (c.discounts = c.discounts.filter((d) => d.discount !== 0)));
this.data = data.data;
this.dataSource = new CustomerListDatasource(this.data, this.paginator, this.sort);
this.cdr.detectChanges();
});
this.sort.sortChange.subscribe({
next: () =>
this.router.navigate([], {
relativeTo: this.route,
queryParams: { f: this.sort?.active ?? 'name', d: this.sort?.direction },
queryParamsHandling: 'merge',
}),
});
this.paginator.page.subscribe({
next: () =>
this.router.navigate([], {
relativeTo: this.route,
queryParams: { s: this.paginator.pageSize, p: this.paginator.pageIndex },
queryParamsHandling: 'merge',
}),
});
this.filter.subscribe({
next: (value) => {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { q: value },
queryParamsHandling: 'merge',
});
},
});
}
ngAfterViewInit() {
setTimeout(() => {
this.filterElement.nativeElement.focus();
}, 0);
}
}

View File

@ -15,8 +15,9 @@ export const routes: Routes = [
permission: 'Customers',
},
resolve: {
list: customerListResolver,
data: customerListResolver,
},
runGuardsAndResolvers: 'always',
},
{
path: 'new',

View File

@ -5,6 +5,7 @@ import { catchError } from 'rxjs/operators';
import { Customer } from '../core/customer';
import { ErrorLoggerService } from '../core/error-logger.service';
import { PagedResult } from '../core/paged-result';
const httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
@ -26,10 +27,18 @@ export class CustomerService {
.pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable<Customer>;
}
list(): Observable<Customer[]> {
list(q: string | null, page = 0, size = 50, field = 'name', direction = 'asc'): Observable<PagedResult<Customer>> {
const params = {
...(q ? { q } : {}),
p: page,
s: size,
f: field,
d: direction,
};
console.log('CustomerService.list', params);
return this.http
.get<Customer[]>(`${url}/list`)
.pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<Customer[]>;
.get<PagedResult<Customer>>(`${url}/list`, { params })
.pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<PagedResult<Customer>>;
}
save(customer: Customer): Observable<Customer> {