jmeinlschmidt

Advanced Sorting with Angular Material Table

Introduction

I consider Angular Material Table to be a powerful component with a truly rich interface and I have been using it not only in large-scale enterprise applications. In the next series I will show how to make the most of this component.

Specifically, this article deals with advanced sorting also using Functional Programming.

Defining the Problem

At the beginning of the solution of every task, it is necessary to define its exact assignment. Let’s have an array of objects fulfilling the following interface:

interface IPurchaseListItem {
  id: number;
  customerName: string;
  warranty: {
    hasWarranty: boolean;
    until?: Date; // Mind that this is an optional property
  }
}

Property until is of data type Date, if property hasWarranty is set to true. It does not always have to be set. In some situations the following condition may occur:

const item: IPurchaseListItem = {
  id: 1;
  customerName: 'Kevin Flynn';
  warranty: {
    hasWarranty: true;
    // Property `until` is missing
  }
}

And the following Material Table with three columns:

<table mat-table matSort [dataSource]="myDataSource">
  <ng-container matColumnDef="id">
    <th mat-header-cell mat-sort-header *matHeaderCellDef>ID</th>
    <td mat-cell *matCellDef="let element">{{ element.id }}</td>
  </ng-container>

  <ng-container matColumnDef="customer">
    <th mat-header-cell mat-sort-header *matHeaderCellDef>Customer</th>
    <td mat-cell *matCellDef="let element">{{ element.customer }}</td>
  </ng-container>

  <ng-container matColumnDef="warranty">
    <th mat-header-cell mat-sort-header *matHeaderCellDef>Warranty</th>
    <td mat-cell *matCellDef="let element">
      <ng-container *ngTemplateOutlet="warrantyCell; context: { $implicit: element.warranty }" />
    </td>
  </ng-container>

  <!-- ... -->
</table>

<ng-template #warrantyCell let-warranty>
  <mat-icon>{{ warranty.hasWarranty ? 'check_circle' : 'cancel' }}</mat-icon>
  <p>{{ warranty.until | date: 'dd.MM.yyyy' }}</p>
</ng-template>

Our goal is to enable sorting on all those three columns. Column warranty will have special sort logic, with the following priorities:

  1. Objects with hasWarranty === true sorted by until date.
  2. Objects with hasWarranty === true but date until === undefined.
  3. Objects with hasWarranty === false.

So we are faced with the problem of sorting with multiple properties.

Material Table Interface

For the purposes of the custom sorting logic, the Material Table (MatTableDataSource to be precise), provides exactly the interface we need. Specifically the property sortData: (data: T[], sort: MatSort) => T[]. See the docs for more information:

Gets a sorted copy of the data array based on the state of the MatSort. Called after changes are made to the filtered data or when sort changes are emitted from MatSort. By default, the function retrieves the active sort and its direction and compares data by retrieving data using the sortingDataAccessor. May be overridden for a custom implementation of data ordering.

In the MatSort object we get, among others, the important property active: string and direction: SortDirection. Where active is the name of the column we are sorting by. The primary solution is to implement our own sorting function for the warrants column.

How Comparators Work

JavaScript has a standardized method sort(compareFn) for Array, which requires the so-called compareFn, i.e. a comparator, to work properly.

It is a function (a, b) => number that compares two elements a, b. The numeric output specifies the order in which the two elements will be in order. Mathematically speaking, it is an ordering relation, but that is not what this article is about.

Sorting Multiple Properties at Once

Our first task is to invent a comparator that can sort IPurchaseListItem by property warranty according to the rules we defined at the beginning. So we will proceed by smaller steps, starting with the property hasWarranty.

Note: For educational purposes, let’s omit the SortDirection for now.

const hasWarrantyCmp = (a: IPurchaseListItem, b: IPurchaseListItem): number
  => Number(a.warranty.hasWarranty) - Number(b.warranty.hasWarranty);

We use the well-known trick of converting a boolean type to a number and then subtracting it. This will achieve the desired behavior. Now let’s focus on the separate sorting by property until.

const warrantyUntilCmp = (a: IPurchaseListItem, b: IPurchaseListItem): number
  => Number(a.warranty.until ?? 0) - Number(b.warranty.until ?? 0);

Also in this case, when we work with the Date type, we can use the conversion to the number data type, for time comparison. In the case when the property until is undefined, we can use the value 0. It will always be smaller than all other possible dates.

Now we have to combine these comparators so that they behave as one single comparator. That is, prioritize hasWarrantyCmp and then use warrantyUntilCmp as a secondary comparator. So the question is: When to apply the secondary comparator? The answer is: We apply the secondary comparator when the primary comparator is unable to decide the order of elements. But we know that it sets just when the comparator returns a value of 0. In that case, we then set the positions according to the secondary comparator:

const warrantyCmp = (a: IPurchaseListItem, b: IPurchaseListItem): number => {
  const primaryCmpResult = hasWarrantyCmp(a, b);

  if (primaryCmpResult === 0) {
    return warrantyUntilCmp(a, b);
  }

  return primaryCmpResult;
};

With this we could consider our comparator solved. But let us see how these principles could be abstracted.

Comparator Logic Abstraction

Let’s first consider what individual comparators do, regardless of the specific domain. After some thought, we notice that hasWarrantyCmp actually compares two values of type boolean. So let’s abstract it into a generic booleanCmp:

We need to get rid of the IPurchaseListItem type. In this case, it worked best for me to use concepts from Functional Programming, specifically Higher-order Function (HoF) and a bit of template polymorphism to access a specific nested property:

const booleanCmp =
  <T>(property(entity: T) => boolean) =>
  (a: T, b: T): number => Number(property(a)) - Number(property(b));

The usage in hasWarrantyCmp will then be as follows:

const hasWarrantyCmp = booleanCmp(
  ({ warranty }: IPurchaseListItem) => warranty.hasWarranty,
);

Think for yourself about the resulting code readability. The second comparator, warrantyUntilCmp, is abstracted similarly to dateCmp. That is, a comparator for comparing objects of type Date.

Chaining Comparators Abstraction

Another question that arises, is it possible to abstract the logic inside the warrantyCmp function? The answer is yes, it is called comparator chaining.

As in formal mathematics, comparators are functions, more generally maps. These can be stacked according to certain rules. This principle can be used here as well.

Our goal is to design a function that accepts any number of comparators and combines them so that if the current comparator returns 0, the next comparator will be applied. This will ensure correct chaining of the comparators.

We will start gradually, so far with only two functions:

type ComparatorFn = <T>(a: T, b: T) => number;

const chainCmps = <T>(
  fnA: ComparatorFn<T>,
  fnB: ComparatorFn<T>,
): ComparatorFn<T> =>
(a: T, b: T): number => {
  // TODO: Implement
};

Then apply the first function, if it fails to decide (i.e. returns 0), apply the second function:

const chainCmps = <T>(
  fnA: ComparatorFn<T>,
  fnB: ComparatorFn<T>,
): ComparatorFn<T> =>
(a: T, b: T): number => {
  let primaryCmpResult = fnA(a, b);

  if (primaryCmpResult === 0) {
    return fnB(a, b);
  }

  return primaryCmpResult;
};

You will notice that so far the principle is the same with the warrantyCmp function we implemented with the previous section. Yet, the usage would be as follows:

const warrantyCmp = chainCmps<IPurchaseListItem>(
  hasWarrantyCmp,
  warrantyUntilCmp,
);

But what if in the future a client comes in and wants to sort by three properties, not two? The solution is to abstract this behavior to any number of comparators, using the rest operator and an array function. We will apply these incrementally according to the procedure we described above.

const chainCmps =
<T>(...cmps: ComparatorFn<T>[]): ComparatorFn<T> =>
(a: T, b: T): number => {
    let result = 0;

    for (const comparator of cmps) {
      result = comparator(a, b);

      if (result !== 0) {
        break;
      }
    }

    return result;
};

Since we are in the world of functional programming, let’s use the reduce() instead of the for-cycle method:

const chainCmps =
<T>(...cmps: ComparatorFn<T>[]): ComparatorFn<T> =>
(a: T, b: T): number =>
  cmps.reduce((acc, current) => (acc === 0 ? current(a, b) : acc), 0);

The usage remains the same, but we have abstracted the function for any number of comparators.

Custom Sort Logic in Material Table

After a long detour, let’s get back to the Material Table. Let’s use our own comparator for custom sorting. As I mentioned at the beginning, the sortData property expects a function that sorts data based on the selected column.

So let’s implement our solution, we have to think about three situations that can occur:

  1. No sort is set: Return the data unchanged.
  2. Sort by warranty column: Use our own warrantyCmp comparator.
  3. Sort by another column: Use the existing logic.

How do we know if the sort is disabled?

const isSortDisabled = (sort: MatSort): boolean =>
  !sort.active || sort.direction === '';

How do we use the default sort logic?

const defaultSortFn =
  <T>(data: T[], sort: MatSort): T[] =>
    new MatTableDataSource<T>([]).sortData(data, sort);

Final solution:

const customSortFn = (
  data: IPurchaseListItem[],
  sort: MatSort,
): IPurchaseListItem[] => {
  if (isSortDisabled) {
    return data;
  }

  if (sort.active === 'warranty') {
    return data.sort(warrantyCmp);
  }

  return defaultSortFn(data, sort);
}

Couldn’t that be abstracted as well?

Custom Sort Logic Configurator

Let’s abstract the creation of customSortFn. So let’s create a HoF to which we pass the requested configuration and it will produce its own customSortFn.

Expected interface:

const customSortFn = customSortFactory({ warranty: warrantyCmp });

Let’s get to it. First, let’s define types:

type ICustomSortFactoryConfiguration<T> = Record<string, ComparatorFn<T>>;

The object used for configuration will contain the name of the column in the Material Table as a key (property matColumnDef) and the value will be the custom comparator.

export const customSortFactory =
  <T>(config: ICustomSortFactoryConfiguration<T>) =>
  (data: T[], sort: MatSort) => {
    if (isSortDisabled(sort)) {
      return data;
    }

    const columnComparatorFn = config[sort.active];

    return columnComparatorFn
      ? data.sort(columnComparatorFn(sort.direction))
      : defaultSortFn<T>()(data, sort);
  };

Conclusion

There is nothing left but to provide our own customSortFn DataSource in the given component:

@Component({
  // ...
})
export class MyTableComponent implements OnInit {
  protected myDataSource = new MatTableDataSource<IPurchaseListItem>();

  // ...

  public ngOnOnit(): void {
    this.myDataSource.sortData = customSortFactory({
      warranty: warrantyCmp,
    });
  }
}

As mentioned before, for educational purposes, this solution omits the SortDirection. This functionality can be easily achieved by multiplying the numeric result of the comparator by the following value:

direction === 'asc' ? 1 : -1;