16
Editing Tabular Data in Angular
Jim Armstrong | ng-conf | May 2019
- A fun dive into everything from custom Directives to advanced ViewChildren
This article targets beginning to intermediate-level Angular developers and covers a wide variety of topics that arise in production applications. While centered around the concept of editing tabular data, these techniques may be used in a variety of other Angular applications.
For anyone who has read at least one of my articles, you should understand that my background is applied mathematics and scientific computing. So, this article continues the trend of exploring the use of Angular in scientific and business (analytics) applications.
Working with time-series data is a fundamental concept in numerous business and engineering sectors. In this context, front-end development is largely concerned with minor transformations and display of data. Concepts such as data grids, tabular display, and visualization with charts are quite familiar to front-end devs. What is likely to be less familiar is the need to edit one or more values in a time series.
Data often comes from physical instruments that have some degree of fallibility and/or manual entry that is subject to typical human error. So, at some point during your FE career, it may be necessary to develop components that facilitate both the display and editing of tabular data. Only the latter is discussed in this article.
Before continuing, point your friendly, neighborhood browser to this Github, so that you can follow along with the project deconstruction.
Techniques covered in the remainder of the article include
Using Angular Material from a feature module
Custom Directives (including @HostListener and @HostBinding)
@ViewChild vs @ViewChildren and subscribing to changes on the latter
Validate while typing
Custom Events
The project is organized into a few simple folders,
— src/app
— — features
— — — table-edit (table edit component, custom directive, and feature module
— — libs (because we always need some custom libraries — otherwise, I would be out of business :)
— — models (all data models)
— — services (because data has to come from somewhere)
The data used in this sample project comes from an actual, historical dataset on used-car sales from the book, “Machine Learning in R” by Lantz. For tutorial purposes, suppose that all data in the table comes from reliable sources except mileage, which is hand-entered in another application. The code provided with this article simulates a use-case where someone with edit and/or approval authority visually examines a series of data to search for outliers. That data is displayed in a table which contains an Input field in one column to support editing that particular item. To make the demo more realistic, the original data was hand-edited to insert a number of outliers.
And, it would not be a project if we did not have some requirements! Every one of the following requirements was taken from an actual client application I’ve worked on in the past.
1 — Display the data in a table with headers and data returned from a service.
2 — One and only one column is editable, the car mileage. This is hardcoded into the application and will not change.
3 — The table should be paged. The number of initial rows and allowable rows for padding will be provided. Allow sorting by date of manufacture as older cars should generally have more mileage.
4 — A user may tab between rows, but indication of an edited value is via pressing ‘Return’. I’ve also been required to add a small button to the side of the input in actual projects, but that’s not required for this demo.
5 — User inputs are validated while typing. Only numerical, integer inputs (with no minus sign) are allowed. If the user enters an incorrect character, reset the input field value to its value when the user first focused on the field (or the most recently edited and valid value).
6 — Inputs fields have a small, grey border by default (color to be provided and not changeable). When the user successfully edits a mileage value, replace the border with a green color (to be provided and not changeable).
7 — Whenever the user navigates to a new page, the input borders should be reset to the default value.
8 — Whenever a user clicks on a row, whether they edit a value or not, record that click and store the number of clicks on each car id to be returned to the server. I actually had a client who wanted to do this to capture ‘interest’ in a particular row of data, i.e. they believed the click was indicative of interest in the data whether the user actually edited the data or not. Okay, well, as long as the money’s there … I don’t care :)
9 — Capture whenever the user moves from one page to another so that we can potentially take action in the future. Yes, folks, that’s a common one … people want to do something, but they won’t know what it is until well into the future.
10 — Add a ‘Save’ button. Clicking on this button will send a record of all edited data to the server. For tutorial purposes, the button will be implemented, but the handler only logs the edited data to the console.
In an actual application, a person with edit authority would perform the data editing and then after saving the data, a person with approval authority would be responsible for viewing all data and approving the modifications. This article is only concerned with the edit portion of the process.
Enough has been written on the use of Material and Material Table in particular, that there is little benefit from adding a lot of explanation in this article. Suffice to say that I personally prefer using ngContainer to create templates for each column. The most important column in the layout provided below is mileage, and there is a Material Input field that allows editing of the mileage values.
<div class="mat-elevation-z8">
<table mat-table matSort [dataSource]="dataSource">
<tr mat-header-row *matHeaderRowDef="displayOrder"></tr>
<tr mat-row *matRowDef="let row; columns: displayOrder" (click)="onTouched(row)"></tr>
<ng-container matColumnDef="year">
<th mat-header-cell *matHeaderCellDef mat-sort-header="year"> Year </th>
<td mat-cell *matCellDef="let element"> {{element.year}} </td>
</ng-container>
<ng-container matColumnDef="model">
<th mat-header-cell *matHeaderCellDef> Model </th>
<td mat-cell *matCellDef="let element"> {{element.model}} </td>
</ng-container>
<ng-container matColumnDef="price">
<th mat-header-cell *matHeaderCellDef> Price </th>
<td mat-cell *matCellDef="let element"> {{element.price}} </td>
</ng-container>
<ng-container matColumnDef="mileage">
<th mat-header-cell *matHeaderCellDef> Mileage </th>
<td mat-cell *matCellDef="let element">
<mat-form-field>
<input matInput class="bordered editable" type="text" min="0" max="1000000" value="{{element.mileage}}" id="{{element.carid}}"
(keyup)="__checkNumber($event)" (inputChanged)="onEdited($event)" >
<mat-hint><strong>Mileage</strong></mat-hint>
</mat-form-field>
</td>
</ng-container>
<ng-container matColumnDef="color">
<th mat-header-cell *matHeaderCellDef> Color </th>
<td mat-cell *matCellDef="let element"> {{element.color}} </td>
</ng-container>
<ng-container matColumnDef="transmission">
<th mat-header-cell *matHeaderCellDef> Transmission </th>
<td mat-cell *matCellDef="let element"> {{element.transmission}} </td>
</ng-container>
</table>
<!-- options should always be Fibonacci :) -->
<mat-paginator [length]="150" [pageSize]="5" [pageSizeOptions]="[5, 8, 13]" showFirstLastButtons (page)="onPage($event)"></mat-paginator>
<div align="right">
<button mat-button color="primary" (click)="onSave()">Save</button>
</div>
</div>
table-edit.component.html hosted on GitHub
Note the inclusion of the Material paginator near the end of the layout.
The necessary Material modules are separated into a feature module as shown in the file
/src/app/features/material.module.ts
import { NgModule } from '@angular/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import {
MatTableModule,
MatPaginatorModule,
MatInputModule,
MatSortModule,
MatButtonModule
} from '@angular/material';
const PLATFORM_IMPORTS: Array<any> = [BrowserAnimationsModule];
const MATERIAL_IMPORTS: Array<any> = [MatTableModule, MatPaginatorModule, MatInputModule, MatSortModule, MatButtonModule];
@NgModule({
imports: [PLATFORM_IMPORTS, MATERIAL_IMPORTS],
exports: MATERIAL_IMPORTS,
declarations: []
})
export class MaterialModule { }
which is imported into the table edit module
/src/app/features/table-edit/table-edit.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MaterialModule } from '../material.module';
import { TableEditComponent } from '../table-edit/table-edit/table-edit.component';
import { InputSelectorDirective } from './directives/input-selector.directive';
export const TABLE_COMPONENTS: Array<any> = [TableEditComponent, InputSelectorDirective];
@NgModule({
imports: [MaterialModule, CommonModule],
exports: TABLE_COMPONENTS,
declarations: TABLE_COMPONENTS
})
export class TableEditModule { }
This allows the table-edit functionality to be easily imported into any project, including ours in /src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { HttpClientModule } from '@angular/common/http';
// feature module
import { TableEditModule } from './features/table-edit/table-edit.module';
// app-level components
import { AppComponent } from './app.component';
const APP_DECLARATIONS: Array<any> = [AppComponent];
@NgModule({
declarations: APP_DECLARATIONS,
imports: [
BrowserModule, HttpClientModule, TableEditModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
All data models (interfaces) for the application are in the /src/app/models/models.ts file. A single record of car data is modeled as
export interface ICarData
{
carid: number;
year: number,
model: string,
price: number
mileage: number;
color: string;
transmission: TransmissionEnum;
}
and the entire model (including headers) is
export interface ICarDataModel
{
header: Array<string>;
data: Array<ICarData>;
}
When the user edits car mileage, it is necessary to record the id of the edited vehicle and the new mileage value, which are stored in an IEditedData instance.
export interface IEditedData
{
id: number;
value: number;
}
The main app component, /src/app/app.component.ts simply loads a car data model from a JSON file and then separates the header and car data into two bound variables in the __onModelLoaded method,
protected __onModelLoaded(result: ICarDataModel): void
{
// this handler could be used to check the integrity of returned data
this.header = result.header.slice();
// assign a copy of the returned model to the bound data
this.data = result.data.map( (car: ICarData): ICarData => {return JSON.parse(JSON.stringify(car))} );
}
From this point, the remainder of the application is handled by the table edit component.
The table edit component (/src/app/features/table-edit/table-edit/table-edit.component.ts) employs an InputSelectorDirective to select individual Input fields. This is accomplished by using a class selector in the Directive,
@Directive({
selector: '.editable'
})
export class InputSelectorDirective implements OnInit
and then applying that class in the Input field in the template,
/src/app/features/table-edit/table-edit/table-edit.component.html
<input matInput class="bordered editable" type="text" min="0" max="1000000" value="{{element.mileage}}" id="{{element.carid}}"
(keyup)="__checkNumber($event)" (inputChanged)="onEdited($event)" >
A ViewChild of this Directive provides a direct reference to a single instance of that Directive, applied to an Input field with the class ‘editable.’ This application, however, requires references to all such input fields in the current table page. This is where ViewChildren and QueryList are used.
/src/app/features/table-edit/table-edit/table-edit.component.ts
@ViewChildren(InputSelectorDirective)
protected _inputs: QueryList<InputSelectorDirective>; // reference to QueryList returned by Angular
protected _inputsArr: Array<InputSelectorDirective>; // Array of Directive references
The QueryList provides a reference to the InputSelectorDirective for all Input fields in the current page.
Two Typescript Records are used to store edited data and record ‘row touches’,
protected _edited: Record<string, number>;
protected _touches: Record<string, number>;
Some programmatic support is required to interface with the Material table, specifically a data source, reference to the MatPaginator (paginator), and MatSort (sorting). This is accomplished with two ViewChild instances and a public variable (for binding)
@ViewChild(MatPaginator)
protected _paginator: MatPaginator;
@ViewChild(MatSort)
protected _sort: MatSort;
// (Material) Datasource for the table display
public dataSource: MatTableDataSource<ICarData>;
That concludes the basic setup for this component. In terms of logic, a summary of relevant class methods follows to aid in your deconstruction of the application.
Method: onEdited(evt: IEditedData): void
This method is called whenever the mileage data is edited. It first checks the argument and event id and then stores the edited data in the class edited-data Record.
Method: onTouched(row: ICarData): void
This method is called whenever a user clicks on a table row, which is taken as an indication of interest in that data, whether it is edited or not. Yes, I’ve actually had to implement this for a client in a real application. As long as the check clears the bank, it works for me :)
Method: onSave(): void
This is a placeholder for you to implement a service call to store the edited data should you wish to modify the code for use in a production environment. The edited data is logged to the console to help visualize formatting of the edited-data Record.
Method: onPage(evt: PageEvent): void
This is another placeholder method in case you want to modify the application to perform some function whenever the user pages to another set of table data.
Method: __checkNumber(evt: any): boolean
This method is called to validate a number while typing. It defers the validation to the library method, Validation.checkNumber(), which is useful for numeric entry of physical properties that must be greater than or equal to zero.
Method: __onInputsChanged(): void
This method is executed whenever the QueryList of Input fields changes (i.e. on page change). The method’s primary action is to reset the border color on all new fields. Modify for additional functionality as you see fit.
Since the QueryList of InputSelectorDirective instances changes every time the user navigates to a new page of the table, it is necessary to subscribe to changes in that list. The subscription is made in the ngAfterViewInit lifecycle method,
public ngAfterViewInit(): void
{
// subscribe to changes in the query list
this._inputs.changes.subscribe( () => this.__onInputsChanged() );
}
and here is the handler,
protected __onInputsChanged(): void
{
// input query list changed (which happens on profile selection)
this._inputsArr = this._inputs.toArray();
// set default border color on everything
if (this._inputsArr && this._inputsArr.length > 0) {
this._inputsArr.forEach( (input: InputSelectorDirective): void => {input.borderColor = '#cccccc'});
}
}
The use of this method and onPage() provides a natural separation of the primary focus on Input field changes with any other activities that may be requested on page change. The result is better focus on single responsibility between methods.
This Directive provides a collection of Output and event handlers to facilitate editing the mileage data in the table.
The single Output is ‘inputChanged’ and is emitted whenever a mileage value is changed,
/src/app/features/table-edit/directives/input-selector.directive.ts
@Output('inputChanged')
protected _changed: EventEmitter<IEditedData>;
A single HostBinding to the border-color style facilitates changing the border color of each Input field based on whether that element is initially displayed or in an edited state.
@HostBinding('style.border-color')
public borderColor: string = '#cccccc';
There are two host listeners, one for the ‘focus’ event and the other for ‘keyup.’ When an Input field receives focus, it is necessary to capture the current value and the id associated with that mileage value. The former is used to re-populate the field with the initial value in the event a typing error is detected. The id must be emitted along with the edited value in order to associate the edited value with a specific record of car data.
The ‘keyup’ listener performs basic validation on the current numerical input for the mileage value. A valid value on clicking ‘Return’ causes the Input field to be colored green. Input errors while typing cause the field to be repopulated with the last-known-good value.
@HostListener('keyup', ['$event']) onKeyUp(evt: KeyboardEvent): boolean
{
// test for singleton leading negative sign as first character
const v: string = this._input.value;
const n: number = v.length;
// for now, allow a blank field as it is possible that the entire number could be deleted by backspace before
// entering a new number
if (n == 0) {
return true;
}
// physical quantities may not be negative and a decimal is currently not allowed
if ( (n == 1 && v == "-") || (evt.key == ".") )
{
this.hasError = true;
this._input.value = this._currentValue.toString();
return true;
}
// check for most recent keystroke being an enter, which is currently the only way to indicate an edit
const code: string = evt.code.toLowerCase();
if (code == 'enter' || code == 'return')
{
if (!isNaN(+v) && isFinite(+v))
{
this.hasError = false;
this._currentValue = +v;
// set 'edited' border color and emit the changed event
this.borderColor = '#66CD00';
this._changed.emit({id: this._currentID, value: +v});
}
else
{
this.hasError = true;
this._input.value = this._currentValue.toString();
}
return true;
}
this.hasError = !Validation.checkNumber(evt);
if (this.hasError)
{
console.log( "error: ", this._currentValue );
// indicate an error by replacing the bad input with the 'current' or last-known good value
// this may be altered in a future release
this._input.value = this._currentValue.toString();
}
return true;
}
input-selector.directive.ts hosted by GitHub
This has been a long and somewhat involved deconstruction. Scientific, engineering, and business-analytic applications often expose a much higher degree of interactivity to FE devs. I hope this article and the supporting code has helped beginning- and intermediate-level Angular developers with their understanding of the platform.
Come learn from community members and leaders the best ways to build reliable web applications, write quality code, choose scalable architectures, and create effective automated tests. Powered by ng-conf, join us for the Reliable Web Summit this August 26th & 27th, 2021.
https://reliablewebsummit.com/
16