Deep dive: Reorder conditional table columns in Angular
At one of our clients, we had to implement a complex table in Angular. Some features of this table were:
- Expandable rows
- Resizable columns width drag and drop
- Select/multi-select with control and shift keys
- Multisorting
- Pagination
The reason why we wrote this ourselves is that this component is core to the business of our client and they want very specific logic. A new feature that was requested was the ability to conditionally show columns based on keys. We would have a string array that contained keys that we would pass to the table component. The next feature request was to determine the order of the columns based on that array:
@Component({
template: `
<table [showColumns]="columnsToShow">
...
</table>
`
})
export class AppComponent{
columnsToShow = ['firstName', 'lastName', 'age']
}
In this pseudo code, we see that a table would have to render the firstName
, lastName
and age
columns in that order.
In this article, we will create a simple table that will render columns conditionally and in the right order.
First simple solution
The first solution is to use *ngFor
and render the columns automatically:
@Component({
...
template: `
<table>
<thead>
<tr *ngFor="let column of columnsToShow">
<th>{{column}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let user of users">
<td *ngFor="let column of columnsToShow">
{{user[column]}}
</td>
</tr>
</tbody>
</table>
`,
})
export class App {
public readonly columnsToShow = ['firstName', 'lastName', 'age', 'gender'];
public readonly users = [
{
firstName: 'Brecht',
lastName: 'Billiet',
age: 35,
gender: 'male'
}
...
];
}
This approach is very limited. It does not support specific implementations in the th
or td
elements.
For instance, showing an icon for the gender would be very hard, since every td
template would be the same.
A second approach (idea)
The second idea that we had was to use structural directives like this:
<table [columnsToShow]="columnsToShow">
<thead>
<tr *ngFor="let column of columnsToShow">
<th>{{column}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let user of users">
<td *conditionalTd="firstName">
{{user.firstName}}
</td>
<td *conditionalTd="lastName">
{{user.lastName}}
</td>
<td *conditionalTd="age">
{{user.age}}
</td>
<td *conditionalTd="gender">
{{user.gender}}
</td>
</tr>
</tbody>
</table>
While this approach would work for conditionally showing columns, it would not work for the reordering of columns.
firstName
would always be shown first, and lastName
, age
and gender
would always be the next columns that would get rendered.
Since inheritance between child structural directives is not possible in Angular, we had to find a new and better solution.
Fixing the issue with content projection
It started to make sense that since the td
and th
elements had to be shown conditionally and the order was of importance, we had to use ng-template
elements.
We would create an [conditional-templates-with-order]
attribute component that would be used on the tr
element. In that component, we would use content-projection to project 4 ng-template
elements:
<tr [conditional-templates-with-order]="columnsToShow">
<ng-template>
<td>{{user.firstName}}</td>
</ng-template>
<ng-template>
<td>{{user.lastName}}</td>
</ng-template>
<ng-template>
<td>{{user.age}}</td>
</ng-template>
<ng-template>
<td>{{user.gender}}</td>
</ng-template>
</tr>
Since we have to identify the ng-template
elements separately, we created a conditional-template
directive where we can pass a key to as an @Input()
property:
@Directive({
selector: '[conditional-template]',
standalone: true
})
export class ConditionalTemplateDirective {
@Input('conditional-template') public conditionalTemplate = '';
}
We could use the new directive like this:
<tr [conditional-templates-with-order]="columnsToShow">
<ng-template conditional-template="firstName">
<td>{{user.firstName}}</td>
</ng-template>
<ng-template conditional-template="lastName">
<td>{{user.lastName}}</td>
</ng-template>
<ng-template conditional-template="age">
<td>{{user.age}}</td>
</ng-template>
<ng-template conditional-template="gender">
<td>{{user.gender}}</td>
</ng-template>
</tr>
This seems like a solid structure to build our tables. Before we continue to the implementation of conditional-templates-with-order
let’s show the entire component with structure.
<table>
<thead>
<tr [conditional-templates-with-order]="columnsToShow">
<!-- We could do an *ngFor but this is more flexible -->
<ng-template conditional-template="firstName">
<td>First name</td>
</ng-template>
<ng-template conditional-template="lastName">
<td>last name</td>
</ng-template>
<ng-template conditional-template="age">
<td>Age</td>
</ng-template>
<ng-template conditional-template="gender">
<td>Gender</td>
</ng-template>
</tr>
</thead>
<tbody>
<!-- Simply loop over the users -->
<tr
*ngFor="let user of users; trackBy: tracker"
[conditional-templates-with-order]="columnsToShow">
<ng-template conditional-template="firstName">
<td>{{user.firstName}}</td>
</ng-template>
<ng-template conditional-template="lastName">
<td>{{user.lastName}}</td>
</ng-template>
<ng-template conditional-template="age">
<td>{{user.age}}</td>
</ng-template>
<ng-template conditional-template="gender">
<td>{{user.gender}}</td>
</ng-template>
</tr>
</tbody>
</table>
@Component({
...
})
export class App {
// All the available columns
public readonly columnsToShow = ['firstName', 'lastName', 'age', 'gender'];
// Our list of users
public readonly users = [
{
firstName: 'Brecht',
lastName: 'Billiet',
age: 35,
gender: 'male',
},
{
firstName: 'Silvie',
lastName: 'Rayée',
age: 32,
gender: 'female',
},
];
}
Implementing ConditionalTemplatesWithOrderComponent
The last thing we need to do is implement the ConditionalTemplatesWithOrderComponent
.
Since we are using an attribute this might look like a directive, but it is not.
It’s a component that we can use on any type of element. In our case a tr
element.
The responsibility of this component is:
- Looping over the passed columns to show
- Creating a template outlet for all these columns
- Look for all the
ng-template
children andConditionalTemplateDirective
children - Look up the right index of the template
- Pass the right template to the component
To make the selector a little bit more strict, let’s make sure that we can only use the component on a tr
element. Since we want to pass columns to show directly to the template let’s create the input like this:
@Component({
...
// selector can only be used on a tr element
selector: 'tr[conditional-templates-with-order]',
})
export class ConditionalTemplatesWithOrderComponent {
// Avoid boilerplate
@Input('conditional-templates-with-order') protected order: string[] = [];
protected getTemplate(key: string): TemplateRef<any>|null {
throw new Error('not implemented yet');
}
}
In our template, we would loop over all the keys of our columns and create a new *ngTemplateOutlet
for every key and we will pass it a TemplateRef
that we want to receive through a getTemplate(key)
method:
<ng-container *ngFor="let key of order; trackBy: tracker">
<ng-container *ngTemplateOutlet="getTemplate(key)"></ng-container>
</ng-container>
Getting access to the children
To render the right TemplateRef
at the right place we need access to all the TemplateRef
children and all the ConditionalTemplateDirective
children of this component. Remember how it is being used (our ConditionalTemplateDirective
always has an ng-template
with a conditional-template
directive applied to it):
<tr [conditional-templates-with-order]="columnsToShow">
<ng-template conditional-template="firstName">
...
</ng-template>
...
</tr>
This is more type-safe then the let-model approach Telerik and Angular material are using.
To get access to those children, we can use ContentChildren
:
export class ConditionalTemplatesWithOrderComponent {
// Get access to `ng-template`
@ContentChildren(TemplateRef)
protected templates!: QueryList<TemplateRef<unknown>>;
// Get access to `conditional-template`
// which holds the key
@ContentChildren(ConditionalTemplateDirective)
protected conditionalTemplateDirectives!: QueryList<ConditionalTemplateDirective>;
@Input('conditional-templates-with-order') protected order: string[] = [];
...
}
Implementing the getTemplate() method
The only thing we need to do is to implement the getTemplate()
method which will loop over the ConditionalTemplateDirective
instances, map it to the actual key that we passed and locate the index of the key passed with the getTemplate()
method.
When we have the index, we can just use the list of ng-template
references and return the right TemplateRef
instance:
export class ConditionalTemplatesWithOrderComponent {
...
protected getTemplate(key: string): TemplateRef<any> | null {
// Get the index in the templates based on the key
const index = this.conditionalTemplateDirectives
.toArray()
.map((item) => item.conditionalTemplate)
.indexOf(key);
// return the right template
return this.templates.toArray()[index];
}
}
Here is the entire implementation of the ConditionalTemplatesWithOrderComponent
:
@Component({
selector: 'tr[conditional-templates-with-order]',
standalone: true,
imports: [CommonModule],
template: `
<ng-container *ngFor="let key of order; trackBy: tracker">
<ng-container *ngTemplateOutlet="getTemplate(key)"></ng-container>
</ng-container>
`,
})
export class ConditionalTemplatesWithOrderComponent {
// Get access to `ng-template`
@ContentChildren(TemplateRef)
protected templates!: QueryList<TemplateRef<any>>;
// Get access to `conditional-template`
// which holds the key
@ContentChildren(ConditionalTemplateDirective)
protected conditionalTemplateDirectives!: QueryList<ConditionalTemplateDirective>;
@Input('conditional-templates-with-order') protected order: string[] = [];
// TrackBy for performance
protected tracker = (i: number) => i;
protected getTemplate(key: string): TemplateRef<any> | null {
// Get the index in the templates based on the key
const index = this.conditionalTemplateDirectives
.toArray()
.map((item) => item.conditionalTemplate)
.indexOf(key);
// return the right template
return this.templates.toArray()[index];
}
}
Test the logic with a multiselect and reverse button
The implementation is finished, but we still want to test if everything works. Let’s add 2 things to our application:
- A multi-select where we can select the columns that need to be shown
- A button to reverse the order
Since template-driven forms
are awesome let’s create a simple select
to determine the columns that need to be shown and let’s add a button to reverse the order of our columns:
<select
[(ngModel)]="columnsToShow"
name="columnsToShow" multiple>
<option [ngValue]="col" *ngFor="let col of allColumns">
{{ col }}
</option>
</select>
<button (click)="reverse()">Reverse the order</button>
export class App {
...
public readonly allColumns = ['firstName', 'lastName', 'age', 'gender'];
public columnsToShow = [...this.allColumns];
public reverse(): void {
this.columnsToShow = [...this.columnsToShow.reverse()];
}
}
Check out the entire solution in StackBlitz here
Summary
Using content projection in Angular can be something very powerful. In this article, we learned how to use ng-template
in combination with @ContentChildren()
and *ngTemplateOutlet
to not only conditionally render elements, but also determine their order. I hope you enjoyed the article!
If you like to learn directly from me, check out my Angular Training and Angular Coaching
Special thanks to the awesome reviewer: -Gregor Woiwode
Follow @TwitterDevJoin the club and get notified with new content
Get notified about our future blog articles by subscribing with the button below.
We won't send spam, unsubscribe whenever you want.
Update cookies preferences