Search code examples
angularangular-material

The sort and URL update is getting stuck in infinite loop


I'm trying to make the triggering on material table's column update the URL like:

http://localhost:4200/cars?find=m&sort=make:desc

Also, when I enter URL that includes sort then the table is expected to be sorted by specific column.

Currently, when I click on table column to sort, the infinite loops gets triggered, resulting in URL changing from

http://localhost:4200/cars?find=m&sort=make:desc

to

http://localhost:4200/cars?find=m&sort=make:asc

car-list.component.html

<div class="example-container mat-elevation-z8">
  <div class="example-header">
    <mat-form-field>
      <input matInput [formControl]="filterControl" placeholder="Search">
    </mat-form-field>
  </div>
  <mat-table [dataSource]="dataSource" matSort>

    <!-- Id Column -->
    <ng-container matColumnDef="id">
      <mat-header-cell *matHeaderCellDef mat-sort-header> Id </mat-header-cell>
      <mat-cell *matCellDef="let car"> {{car.id}} </mat-cell>
    </ng-container>

    <!-- Make Column -->
    <ng-container matColumnDef="make">
      <mat-header-cell *matHeaderCellDef mat-sort-header> Make </mat-header-cell>
      <mat-cell *matCellDef="let car"> {{ car.make }} </mat-cell>
    </ng-container>

    <!-- Model Column -->
    <ng-container matColumnDef="model">
      <mat-header-cell *matHeaderCellDef mat-sort-header> Model </mat-header-cell>
      <mat-cell *matCellDef="let car"> {{ car.model }} </mat-cell>
    </ng-container>

    <!-- Numberplatez Column -->
    <ng-container matColumnDef="numberplate">
      <mat-header-cell *matHeaderCellDef mat-sort-header> Numberplate </mat-header-cell>
      <mat-cell *matCellDef="let car"> {{ car.numberplate }} </mat-cell>
    </ng-container>

    <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
    <mat-row *matRowDef="let row; columns: displayedColumns;" (click)="navigateToCarDetail(row)"></mat-row>
  </mat-table>
</div>

car-list.component.ts

import { Component, OnInit, ViewChild, AfterViewInit } from '@angular/core';
import { MatSort, Sort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { Car } from "../models/car.model";
import { ActivatedRoute, Router } from '@angular/router';
import { CarService } from '../services/car.service';
import { FormControl } from "@angular/forms";
import { debounceTime, takeUntil } from "rxjs/operators";
import { Subject } from 'rxjs';

@Component({
  selector: 'app-cars',
  templateUrl: './car-list.component.html',
  styleUrls: ['./car-list.component.css']
})
export class CarListComponent implements OnInit, AfterViewInit {
  displayedColumns: string[] = ['id', 'make', 'model', 'numberplate'];
  dataSource: MatTableDataSource<Car>;
  cars: Car[] = [];
  filterControl: FormControl = new FormControl('');
  private ngUnsubscribe = new Subject<void>();
  private sort!: MatSort;

  // Sorting state variables
  private sortField: string | null = null;
  private sortDirection: 'asc' | 'desc' = 'asc'; // Default ascending

  constructor(private carService: CarService,
              private route: ActivatedRoute,
              private router: Router) {
    this.dataSource = new MatTableDataSource<Car>();
  }

  @ViewChild(MatSort) set matSort(ms: MatSort) {
    this.sort = ms;
    if (this.sort) {
      // Subscribe to sort changes
      this.sort.sortChange.pipe(
        debounceTime(300),
        takeUntil(this.ngUnsubscribe)
      ).subscribe((sortState: Sort) => {
        this.applySort(sortState);
      });
    }
  }

  ngOnInit() {
    this.initializeTable();
  }

  ngAfterViewInit() {
    // Set initial sorting and filter from query params after view is initialized
    this.route.queryParams.pipe(takeUntil(this.ngUnsubscribe)).subscribe(params => {
      const { sort, find } = params;
      // Apply initial filter
      this.filterControl.setValue(find || '');
      this.applyFilter(find || '');

      // Apply initial sorting
      if (sort) {
        const [field, direction] = sort.split(':');
        if (field && direction) {
          this.sortField = field;
          this.sortDirection = direction as 'asc' | 'desc';
          if (this.sort) {
            this.sort.sort({ id: field, start: direction as 'asc' | 'desc', disableClear: true });
          }
        }
      }
    });
  }

  initializeTable() {
    // Fetch cars from service
    this.carService.getCars().subscribe((data: Car[]) => {
      this.cars = data;
      this.dataSource.data = this.cars;
    });

    // Set filter predicate
    this.dataSource.filterPredicate = (data, filter: string): boolean => {
      return data.make.toLowerCase().includes(filter) || data.model.toLowerCase().includes(filter) || data.numberplate.toLowerCase().includes(filter);
    };

    // Subscribe to changes in the filter control with debounce
    this.filterControl.valueChanges.pipe(
      debounceTime(300),
      takeUntil(this.ngUnsubscribe)
    ).subscribe(value => {
      this.applyFilter(value);
    });
  }

  applyFilter(filterValue: string) {
    filterValue = filterValue.trim().toLowerCase();
    this.dataSource.filter = filterValue;
    // Update the URL with the new filter value or remove the parameter if the filter is empty
    this.router.navigate([], {
      relativeTo: this.route,
      queryParams: filterValue ? { find: filterValue } : {},
      queryParamsHandling: filterValue ? 'merge' : ''
    });
  }

  applySort(sortState: Sort) {
    const sortField = sortState.active;
    const sortDirection = sortState.direction as 'asc' | 'desc';

    // Update component's sorting state
    this.sortField = sortField;
    this.sortDirection = sortDirection;

    // Update the URL with the new sort value
    this.router.navigate([], {
      relativeTo: this.route,
      queryParams: { sort: `${sortField}:${sortDirection}` },
      queryParamsHandling: 'merge'
    });
  }

  navigateToCarDetail(car: Car): void {
    this.router.navigate(['/cars', car.id]);
  }

  ngOnDestroy() {
    this.ngUnsubscribe.next();
    this.ngUnsubscribe.complete();
  }
}

UPDATE 1:

  1. Got rid of the code inside ViewChild
  2. Updated HTML

The infinite loop is gone but the code not is not sorting the table

Current state:

car-list.component.ts

import { Component, OnInit, ViewChild, AfterViewInit } from '@angular/core';
import { MatSort, Sort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { Car } from "../models/car.model";
import { ActivatedRoute, Router } from '@angular/router';
import { CarService } from '../services/car.service';
import { FormControl } from "@angular/forms";
import { debounceTime, takeUntil } from "rxjs/operators";
import { Subject } from 'rxjs';

@Component({
  selector: 'app-cars',
  templateUrl: './car-list.component.html',
  styleUrls: ['./car-list.component.css']
})
export class CarListComponent implements OnInit, AfterViewInit {
  displayedColumns: string[] = ['id', 'make', 'model', 'numberplate'];
  dataSource: MatTableDataSource<Car>;
  cars: Car[] = [];
  filterControl: FormControl = new FormControl('');
  private ngUnsubscribe = new Subject<void>();

  @ViewChild(MatSort) sort!: MatSort;

  constructor(private carService: CarService,
              private route: ActivatedRoute,
              private router: Router) {
    this.dataSource = new MatTableDataSource<Car>();
  }

  ngOnInit() {
    this.initializeTable();
  }

  ngAfterViewInit() {
    this.route.queryParams.pipe(takeUntil(this.ngUnsubscribe)).subscribe(params => {
      const { find } = params;
      this.filterControl.setValue(find || '');
      this.applyFilter(find || '');
    });
  }

  initializeTable() {
    // Fetch cars from service
    this.carService.getCars().subscribe((data: Car[]) => {
      this.cars = data;
      this.dataSource.data = this.cars;
    });

    // Set filter predicate
    this.dataSource.filterPredicate = (data, filter: string): boolean => {
      return data.make.toLowerCase().includes(filter) || data.model.toLowerCase().includes(filter) || data.numberplate.toLowerCase().includes(filter);
    };

    // Subscribe to changes in the filter control with debounce
    this.filterControl.valueChanges.pipe(
      debounceTime(300),
      takeUntil(this.ngUnsubscribe)
    ).subscribe(value => {
      this.applyFilter(value);
    });
  }

  applyFilter(filterValue: string) {
    filterValue = filterValue.trim().toLowerCase();
    this.dataSource.filter = filterValue;
    // Update the URL with the new filter value or remove the parameter if the filter is empty
    this.router.navigate([], {
      relativeTo: this.route,
      queryParams: filterValue ? { find: filterValue } : {},
      queryParamsHandling: filterValue ? 'merge' : ''
    });
  }

  applySort(sortState: Sort) {
    const sortField = sortState.active;
    const sortDirection = sortState.direction as 'asc' | 'desc';

    // Update the URL with the new sort value
    this.router.navigate([], {
      relativeTo: this.route,
      queryParams: { sort: `${sortField}:${sortDirection}` },
      queryParamsHandling: 'merge'
    });
  }

  navigateToCarDetail(car: Car): void {
    this.router.navigate(['/cars', car.id]);
  }
}

car-list.component.html

<div class="example-container mat-elevation-z8">
  <div class="example-header">
    <mat-form-field>
      <input matInput [formControl]="filterControl" placeholder="Search">
    </mat-form-field>
  </div>
  <mat-table [dataSource]="dataSource" matSort (matSortChange)="applySort($event)">

    <!-- Id Column -->
    <ng-container matColumnDef="id">
      <mat-header-cell *matHeaderCellDef mat-sort-header> Id </mat-header-cell>
      <mat-cell *matCellDef="let car"> {{car.id}} </mat-cell>
    </ng-container>

    <!-- Make Column -->
    <ng-container matColumnDef="make">
      <mat-header-cell *matHeaderCellDef mat-sort-header> Make </mat-header-cell>
      <mat-cell *matCellDef="let car"> {{ car.make }} </mat-cell>
    </ng-container>

    <!-- Model Column -->
    <ng-container matColumnDef="model">
      <mat-header-cell *matHeaderCellDef mat-sort-header> Model </mat-header-cell>
      <mat-cell *matCellDef="let car"> {{ car.model }} </mat-cell>
    </ng-container>

    <!-- Numberplatez Column -->
    <ng-container matColumnDef="numberplate">
      <mat-header-cell *matHeaderCellDef mat-sort-header> Numberplate </mat-header-cell>
      <mat-cell *matCellDef="let car"> {{ car.numberplate }} </mat-cell>
    </ng-container>

    <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
    <mat-row *matRowDef="let row; columns: displayedColumns;" (click)="navigateToCarDetail(row)"></mat-row>
  </mat-table>
</div>

UPDATE 2:

The full app project is available here

https://github.com/mr-olufsen/webapp

I hope it's easy to reproduce

UPDATE 3:

I expected the existing code filter functionality to work together with sort and be reflected in URL.

  1. Empty filter should resolve to /cars (not /cars?filter=)

  2. The sort is expected to be in URL only if it's asc or desc

When I tested the solution:

  1. When I go to /cars?find=&sort=make:asc the URL resolves into /cars but I expected: /cars?sort=make:asc

  2. When I go to /cars?find=4&sort=model:desc works as expected. Sorting is working. URL is updated accordingly. Thanks to Naren Murali

  3. When I go to /cars and click on header table columns, URL is expected to change to something like /cars?sort=make:asc. Actually the table is sorted but URL stays the same /cars


Solution

  • matSort has a change event. Use that, and get rid of the code inside ViewChild; the code gets called multiple times (getters and setters are called during every change detection cycle), due to change detection. It's a very bad place to use the subscribe.

    HTML:

    <mat-table [dataSource]="dataSource" matSort (matSortChange)="applySort($event)">
    

    TS:

    @ViewChild(MatSort) sort!: MatSort;
    

    To make sorting work you need a custom function, that will perform the sorting.

    function compare(a: number | string, b: number | string, isAsc: boolean) {
      return (a < b ? -1 : 1) * (isAsc ? 1 : -1);
    }
    ...
    
    ...
    applySort(sort: Sort) {
        const data = this.users.slice();
        if (!sort.active || sort.direction === '') {
          this.dataSource.data = data;
          return;
        }
    
        this.dataSource.data = data.sort((a, b) => {
          const isAsc = sort.direction === 'asc';
          switch (sort.active) {
            case 'make':
              return compare(a.make, b.make, isAsc);
            case 'model':
              return compare(a.model, b.model, isAsc);
            case 'numberplate':
              return compare(a.numberplate, b.numberplate, isAsc);
            case 'id':
              return compare(a.id, b.id, isAsc);
            case 'fat':
            default:
              return 0;
          }
        });
    }
    ...
    

    Working GitHub Repo