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:
- Objects with
hasWarranty === true
sorted byuntil
date. - Objects with
hasWarranty === true
but dateuntil === undefined
. - 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:
- No sort is set: Return the data unchanged.
- Sort by
warranty
column: Use our ownwarrantyCmp
comparator. - 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;