Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / ASP.NET / ASP.NET-Core

SPA^2 using ASP.NET Core 1.1 + Angular 2.4 - Part 5

5.00/5 (14 votes)
16 Mar 2017CPOL17 min read 21.5K   281  
Create a simple data grid that provides list, add, edit and delete and uses a simple 'parent/child' template to provide view, edit or add functionality
In this article, you will learn how about adding a simple Angular 2 datagrid, adding server-side model-driven data validation and async CRUD operations to our Web API services and more ASP.NET Core tag helpers.

Introduction

If you are looking for an off the shelf framework, or want server side pre-rendering and webpack, then stop, this is not for you. If you want low risk and don't like bleeding edge new beta stuff, then stop here too.

On the other hand, if you like to try something different, learn a few new things (even if they are in beta, or break a little), then read on.

This series of articles are intended to share with you building blocks that you can use in your own code. These are based on a technique to integrate ASP.NET Core and Angular 2 using ASP.NET Core MVC views in place of plain flat 'dumb' HTML and also to use tag helpers to make these even 'smarter', with less repetitive cut/paste code.

This series has been gradually building more components of a SPA using both ASP.NET Core MVC and Angular 2, and working toward the goal of data driven everything (well as much as possible). To save not seeing the 'forest for the trees', I've also kept the examples in the code to use a small number of data types, and, though I am a firm believer in TDD, I've left out unit tests to save obscuring the code being discussed. If there is sufficient interest and support, these further data types and unit tests might be added later in the series.

Background

If you are starting here, perhaps you might like to review the previous parts:

Part 2 showed how to use our server side C# data model to dynamically generate type safe Angular 2 markup and HTML tags, using ASP.NET Core tag helpers. Part 3 went further to include data input and a SQL backend. Part 4 added JWT token security using OpenIDDict and was also a practical example of using the tag helpers and views from Part 3.

Here in Part 5, we will add tag helpers that work within a table, creating a simple data grid. The data grid provides list, add, edit and delete and uses a simple 'parent/child' template to provide view, edit or add functionality.

Async "CRUD" Web API Services

The Web API data services have been a little basic, consisting of a simple get and add method, with validation limited to the front end. See Part 3 where we added use tag helpers to generate the required markup.

We’re now going to fill out the Web API services, add full CRUD (Create / Read / Update / Delete) support with ‘async’ (or asynchronous) calls to the database. We’ll also add server side validation which (like the client side validation) will also be driven by the meta data from our data model.

Before we update the data services, go to the Helpers folder, add a new class called DataAnnotations.cs and edit this, adding a new extension method using the following:

C#
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

namespace A2SPA.Helpers
{
    public static class DataAnnotationsValidator
    {
        // created extension method based on ideas from K. Scott Allen, lifted from:
        // http://odetocode.com/Blogs/scott/archive/2011/06/29/
        // manual-validation-with-data-annotations.aspx
        public static bool IsModelValid(this object @object, 
                                        out ICollection<ValidationResult> results)
        {
            var context = new ValidationContext(@object, serviceProvider: null, items: null);
            results = new List<ValidationResult>();
            return Validator.TryValidateObject(
                @object, context, results,
                validateAllProperties: true
            );
        }
    }
}

If we were to have one method to, say, check if the data was valid, say IsValid(), returning a Boolean true/false, we would then still need another method, say ValidationErrors(), to return the results. And while you could have your IsValid() method, call the VaildationErrors() method to return a boolean, you'd be calling it a second time to get the validation reasons - neither of these make your code particularly simple or efficient. Instead, this new IsModelValid() method is able to provide two different results; both a boolean result true/false if the model data is valid, and through the unconventional “out” parameter, we are able to get the error list, if there was any, all in one call. This works in a similar way to the inbuilt TryParse() method.

To use this new IsModelValid() method, we need to first create an empty list of ICollection<ValidationResult> that will be passed to the method as a parameter. The method’s return type is Boolean, so if we have a validation error, the result from the method will be a boolean result of false and at the same time, the collection will be returned and be populated with our validation errors. If the validation is successful, we get a true result and our empty collection is simply returned empty.

C#
using A2SPA.Data;
using A2SPA.Helpers;
using A2SPA.ViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;

namespace A2SPA.Api
{
    [Authorize]
    [Route("api/[controller]")]
    public class SampleDataController : Controller
    {
        private readonly A2spaContext _context;

        public SampleDataController(A2spaContext context)
        {
            _context = context;
        }

        // GET: api/sampleData/{1}
        [HttpGet("{id}")]
        public IActionResult GetById(int id)
        {
            var testData = _context.TestData
                                   .DefaultIfEmpty(null as TestData)
                                   .FirstOrDefault(a => a.Id == id);

            if (testData == null)
            {
                return Json(NoContent());
            }

            return Json(Ok(testData));
        }

        // GET: api/sampleData
        [HttpGet]
        public IActionResult Get()
        {
            var testData = _context.TestData;

            if (!testData.Any())
            {
                return Json(NoContent());
            }

            return Json(Ok(testData.ToList()));
        }

        // POST api/sampleData
        [HttpPost]
        public IActionResult Post([FromBody]TestData value)
        {
            value.Id = 0;
            ICollection<ValidationResult> results = new List<ValidationResult>();

            if (!value.IsModelValid(out results))
            {
                return Json(BadRequest(results));
            }

            var newTestData = _context.Add(value);
            _context.SaveChanges();

            return Json(Ok(newTestData.Entity as TestData));
        }

        // PUT api/sampleData/5
        [HttpPut]
        public IActionResult Put([FromBody]TestData value)
        {
            ICollection<ValidationResult> results = new List<ValidationResult>();

            if (!value.IsModelValid(out results))
            {
                return Json(BadRequest(results));
            }

            bool recordExists = _context.TestData.Where(a => a.Id == value.Id).Any();

            if (recordExists)
            {
                _context.Update(value);
                _context.SaveChanges();
                return Json(Ok(value));
            }

            return Json(NoContent());
        }

        // DELETE api/sampleData/5
        [HttpDelete("{id:int}")]
        public IActionResult Delete(int id)
        {
            if (id > 0)
            {
                TestData testData = _context.TestData
                                    .DefaultIfEmpty(null as TestData)
                                    .FirstOrDefault(a => a.Id == id);
                if (testData != null)
                {
                    _context.Remove(testData);
                    _context.SaveChanges();
                    return Json(Ok("deleted"));
                }
            }

            return Json(NotFound("Record not found; not deleted"));
        }
    }
}

Notice the return message type has changed from our TestData type, where previously we returned a copy of data just added to get the .id of the new item. Now we'll expand the capabilities of the method by wrapping our result and any success/error messages in the inbuilt properties of an IActionResult type. Furthermore, by using the Ok(result) method, we generate an HTTP error code 200 (meaning success) and the data as well, and by using a number of other convenient return methods such as BadRequest(result), we can make use of HTTP error code 400 to indicate a validation error, literally a bad result.

Take care when selecting the return result method as some of the return methods could generate reserved HTTP error codes or be confusing to end users (and other developers).

The code in SampleDataController.cs can be updated using the final version below. The database is now accessed using async (or ‘asynchronous’) calls, model driven data validation and now there are improved success/error messages and exception handling:

C#
using A2SPA.Data;
using A2SPA.Helpers;
using A2SPA.ViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;

namespace A2SPA.Api
{
    [Authorize]
    [Route("api/[controller]")]
    public class SampleDataController : Controller
    {
        private readonly A2spaContext _context;

        public SampleDataController(A2spaContext context)
        {
            _context = context;
        }

        // GET: api/sampleData/{1}
        [HttpGet("{id}")]
        public async Task<IActionResult> GetById(int id)
        {
            var testData = await _context.TestData
                                   .DefaultIfEmpty(null as TestData)
                                   .SingleOrDefaultAsync(a => a.Id == id);

            if (testData == null)
            {
                return Json(NoContent());
            }

            return Json(Ok(testData));
        }

        // GET: api/sampleData
        [HttpGet]
        public async Task<IActionResult> Get()
        {
            var testData = _context.TestData;

            if (!testData.Any())
            {
                return Json(NoContent());
            }

            return Json(Ok(await testData.ToListAsync()));
        }

        // POST api/sampleData
        [HttpPost]
        public async Task<IActionResult> Post([FromBody]TestData value)
        {
            ICollection<ValidationResult> results = new List<ValidationResult>();

            if (!value.IsModelValid(out results))
            {
                return Json(BadRequest(results));
            }

            try
            {
                value.Id = 0;
                var newTestData = _context.AddAsync(value);
                await _context.SaveChangesAsync();

                return Json(Ok(newTestData.Result.Entity as TestData));
            }
            catch (DbUpdateException exception)
            {
                Debug.WriteLine("An exception occurred: {0}, {1}", 
                                 exception.InnerException, exception.Message);
                return Json(NotFound("An error occurred; new record not saved"));
            }
        }

        // PUT api/sampleData/5
        [HttpPut]
        public async Task<IActionResult> Put([FromBody]TestData value)
        {
            ICollection<ValidationResult> results = new List<ValidationResult>();

            if (!value.IsModelValid(out results))
            {
                return Json(BadRequest(results));
            }

            bool recordExists = _context.TestData.Where(a => a.Id == value.Id).Any();

            if (!recordExists)
            {
                return Json(NoContent());
            }

            try
            {
                _context.Update(value);
                await _context.SaveChangesAsync();
                return Json(Ok(value));
            }
            catch (DbUpdateException exception)
            {
                Debug.WriteLine("An exception occurred: {0}, {1}", 
                                 exception.InnerException, exception.Message);
                return Json(NotFound("An error occurred; record not updated"));
            }
        }

        // DELETE api/sampleData/5
        [HttpDelete("{id:int}")]
        public async Task<IActionResult> Delete(int id)
        {
            var testData = await _context.TestData
                                         .AsNoTracking()
                                         .SingleOrDefaultAsync(m => m.Id == id);

            if (testData == null)
            {
                return Json(NotFound("Record not found; not deleted"));
            }

            try
            {
                _context.TestData.Remove(testData);
                await _context.SaveChangesAsync();
                return Json(Ok("deleted"));
            }
            catch (DbUpdateException exception) 
            {
                Debug.WriteLine("An exception occurred: {0}, {1}", 
                                 exception.InnerException, exception.Message);
                return Json(NotFound("An error occurred; not deleted"));
            }
        }
    }
}

Angular Service + Component CRUD Support

To support the changes just done in our backend, we’ll first need to modify the Angular services. Change the file SampleData.service.ts to the following:

JavaScript
import { Injectable } from '@angular/core';
import { Http, Response, Headers } from '@angular/http';

import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/throw';
import { Observer } from 'rxjs/Observer';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/catch';

import { TestData } from '../models/testData';
import { AuthService } from '../security/auth.service';

@Injectable()
export class SampleDataService {

    private url: string = 'api/sampleData';

    constructor(private http: Http, private authService: AuthService) { }

    getSampleData() {
        return this.http.get(this.url, { headers: this.authService.authJsonHeaders() })
            .map((resp: Response) => resp.json())
            .catch(this.handleError);
    }

    addSampleData(testData: TestData) {
        return this.http
            .post(this.url, JSON.stringify(testData), 
                 { headers: this.authService.authJsonHeaders() })
            .map((resp: Response) => resp.json())
            .catch(this.handleError);
    }

    editSampleData(testData: TestData) {
        return this.http
            .put(this.url, JSON.stringify(testData), 
                { headers: this.authService.authJsonHeaders() })
            .map((resp: Response) => resp.json())
            .catch(this.handleError);
    }

    deleteRecord(itemToDelete: TestData) {
        return this.http.delete(this.url + '/' + itemToDelete.id, 
               { headers: this.authService.authJsonHeaders() })
            .map((res: Response) => res.json())
            .catch(this.handleError);
    }

    // from https://angular.io/docs/ts/latest/guide/server-communication.html
    private handleError(error: Response | any) {
        // In a real world app, we might use a remote logging infrastructure
        let errMsg: string;
        if (error instanceof Response) {
            const body = error.json() || '';
            const err = body.error || JSON.stringify(body);
            errMsg = `${error.status} - ${error.statusText || ''} ${err}`;
        } else {
            errMsg = error.message ? error.message : error.toString();
        }
        console.error(errMsg);
        return Observable.throw(errMsg);
    }
}

Now we have support for each of Create (Add), Read, Update (Edit), and Delete. Although we’re not using it here, I’ve added a get method that fetches a single record by ID to demonstrate how this would be implemented.

Since our data from the server is now wrapped inside an IActionResult we’ll create a new client side data model to match. Add a new typescript class called ViewModelResponse.ts to the /wwwroot/app/models folder, it should be edited to this:

JavaScript
import { Component } from '@angular/core';

export class ViewModelResponse {
    value: any;
    formatters: any[];
    contentTypes: any[];
    declaredType: any;
    statusCode: number;
}

The data will be returned in the value property, and the HTTP status code in the ‘statusCode’ property.

When there is an error, as error mesages are embeddded in the error.value property, we'll create a further data model in typescript so that we can deal with these more readily. In the same folder, beside the new ViewModelResponse.ts file, create another typescript file and call this one ErrorResponse.ts, then edit it to contain the following:

JavaScript
import { Component } from '@angular/core';

export class ErrorResponse {
    memberNames: string;
    errorMessage: string;
}

Next, the about component about.component.ts to support the new CRUD operations:

JavaScript
import { Component, OnInit } from '@angular/core';
import { SampleDataService } from './services/sampleData.service';
import { TestData } from './models/testData';
import { ViewModelResponse } from './models/viewModelResponse';
import { ErrorResponse } from './models/errorResponse';
import { ToastrService } from 'ngx-toastr';
import { Observable } from 'rxjs/Rx';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/catch';

@Component({
    selector: 'my-about',
    templateUrl: '/partial/aboutComponent'
})

export class AboutComponent implements OnInit {
    testDataList: TestData[] = [];
    selectedItem: TestData = null;
    testData: TestData = null;
    tableMode: string = 'list';
    showForm: boolean = true;

    errorMessage: string;

    constructor(private sampleDataService: SampleDataService, 
                private toastrService: ToastrService) { }

    initTestData(): TestData {
        var newTestData = new TestData();
        return newTestData;
    }

    ngOnInit() {
        this.getTestData();
        this.testData = this.initTestData();
        this.selectedItem = null;
        this.tableMode = 'list';
    }

    showSuccess(title: string, message: string) {
        this.toastrService.success(message, title);
    }

    showError(title: string, message: string) {
        this.toastrService.error(message, title);
    }

    changeMode(newMode: string, thisItem: TestData, event: any): void {
        event.preventDefault();
        this.tableMode = newMode;
        if (this.testDataList.length == 0) {
            this.tableMode = 'add';
        }
        else
            if (this.testData == null) {
                this.testData = this.initTestData();
            }

        switch (newMode) {
            case 'add':
                this.testData = this.initTestData();
                break;

            case 'edit':
                this.testData = Object.assign({}, thisItem);
                break;

            case 'list':
            default:
                this.testData = Object.assign({}, thisItem);
                break;
        }
    }

    selectCurrentItem(thisItem: TestData, event: any) {
        event.preventDefault();
        this.selectedItem = thisItem;
        this.testData = Object.assign({}, thisItem);
    }

    formattedErrorResponse(error: ErrorResponse[]): string {
        var plural = (error.length > 0) ? 's' : '';
        var errorMessage = "Error" + plural + ": ";
        for (var i = 0; i < error.length; i++) {
            if (error.length > 0) errorMessage += "(" + (i + 1) + ") ";
            errorMessage += "field: " + error[0].memberNames + ", error: " + 
                             error[0].errorMessage;
            if (i < error.length) errorMessage += ", ";
        }
        return errorMessage;
    }

    addTestData(event: any) {
        event.preventDefault();
        if (!this.testData) { return; }
        this.sampleDataService.addSampleData(this.testData)
            .subscribe((data: ViewModelResponse) => {
                if (data != null && data.statusCode == 200) {
                    //use this to save network traffic; just pushes new record into existing
                    this.testDataList.push(data.value);
                    // or keep these 2 lines; subscribe to data, 
                    // but then refresh all data anyway
                    //this.testData = data.value;
                    //this.getTestData();
                    this.showSuccess('Add', "data added ok");
                }
                else {
                    this.showError('Add', this.formattedErrorResponse(data.value));
                }
            },
            (error: any) => {
                this.showError('Get', JSON.stringify(error));
            });
    }

    getTestData() {
        this.sampleDataService.getSampleData()
            .subscribe((data: ViewModelResponse) => {
                if (data != null && data.statusCode == 200) {
                    this.testDataList = data.value;
                    this.showSuccess('Get', "data fetched ok");
                    if (this.testDataList != null && this.testDataList.length > 0) {
                        this.selectedItem = this.testDataList[0];
                    }
                }
                else {
                    this.showError('Get', "An error occurred");
                }
            },
            (error: any) => {
                this.showError('Get', JSON.stringify(error));
            });
    }

    editTestData(event: any) {
        event.preventDefault();
        if (!this.testData) { return; }
        this.sampleDataService.editSampleData(this.testData)
            .subscribe((data: ViewModelResponse) => {
                if (data != null && data.statusCode == 200) {
                    this.showSuccess('Update', "updated ok");
                    this.testData = data.value;
                    this.getTestData();
                }
                else {
                    this.showError('Update', this.formattedErrorResponse(data.value));
                }
            },
            (error: any) => {
                this.showError('Update', JSON.stringify(error));
            });
    }

    deleteRecord(itemToDelete: TestData, event: any) {
        event.preventDefault();
        this.sampleDataService.deleteRecord(itemToDelete)
            .subscribe((data: ViewModelResponse) => {
                if (data != null && data.statusCode == 200) {
                    this.showSuccess('Delete', data.value);
                    this.getTestData();
                }
                else {
                    this.showError('Delete', "An error occurred");
                }
            },
            (error: any) => {
                this.showError('Delete', JSON.stringify(error));
            });
    }
}

The About component, above, now includes support for the new edit/add functions. To demonstrate how a 3rd party library might be implemented, I’ve chosen ngx-toastr which is a simple popup ‘toast’ notification service. Now, when there is an error, instead of logging to the console (which you can still do, if you wish), a “toast” message is created.

BTW, in addition to success or error, a simple message is created to the user to tell them of the error, using the property name + error message from our Web API data validation. This is an excerpt from about.component.ts and should really be moved, as you expand your application, off into a central library component, then be more readily shared among your other components.

JavaScript
...

    formattedErrorResponse(error: ErrorResponse[]): string {
        var plural = (error.length > 0) ? 's' : '';
        var errorMessage = "Error" + plural + ": ";
        for (var i = 0; i < error.length; i++) {
            if (error.length > 0) errorMessage += "(" + (i + 1) + ") ";
            errorMessage += "field: " + error[0].memberNames + ", error: " + 
                             error[0].errorMessage;
            if (i < error.length) errorMessage += ", ";
        }
        return errorMessage;
    }

...

Toasts are popup messages that can be configured to last a few seconds and fade away (using the default, as done here).

To implement ngx-toastr, edit your package.json file adding the following:

JavaScript
"ngx-toastr": "^4.3.0"

Ensure that you have commas between lines, except the last, in each block of the JSON config file.

Next update the script blocks in _Layout.cshtml to load the required script:

HTML
    <environment names="Development">

. . .

        <link rel="stylesheet" href="/node_modules/ngx-toastr/toastr.css" />

    </environment>

    <environment names="Staging,Production">

. . .  

     <link rel="stylesheet" href="/node_modules/ngx-toastr/toastr.css" />

    </environment>

We’ll need to update systemjs.config.js to point to the new component, add this to the //other libraries section:

JavaScript
'ngx-toastr': 'node_modules/ngx-toastr/toastr.umd.js'

The final systemjs.config.js file will then become:

JavaScript
/**
 * System configuration for Angular samples
 * Adjust as necessary for your application needs.
 */
(function (global) {
  System.config({
    paths: {
      // paths serve as alias
      'npm:': 'node_modules/'
    },
    // map tells the System loader where to look for things
    map: {
      // our app is within the app folder
      app: 'app',

      // angular bundles
      '@angular/core': 'npm:@angular/core/bundles/core.umd.js',
      '@angular/common': 'npm:@angular/common/bundles/common.umd.js',
      '@angular/compiler': 'npm:@angular/compiler/bundles/compiler.umd.js',
      '@angular/platform-browser': 
      'npm:@angular/platform-browser/bundles/platform-browser.umd.js',
      '@angular/platform-browser-dynamic': 
      'npm:@angular/platform-browser-dynamic/bundles/platform-browser-dynamic.umd.js',
      '@angular/http': 'npm:@angular/http/bundles/http.umd.js',
      '@angular/router': 'npm:@angular/router/bundles/router.umd.js',
      '@angular/forms': 'npm:@angular/forms/bundles/forms.umd.js',

      // other libraries
      'rxjs':                      'npm:rxjs',
      'angular-in-memory-web-api': 
      'npm:angular-in-memory-web-api/bundles/in-memory-web-api.umd.js',
      'ngx-toastr': 'node_modules/ngx-toastr/toastr.umd.js'
    },
    // packages tells the System loader how to load when no filename and/or no extension
    packages: {
      app: {
        main: './main.js',
        defaultExtension: 'js'
      },
      rxjs: {
        defaultExtension: 'js'
      }
    }
  });
})(this);

And finally, update app.module.ts to include this in the imports section at the top of the file:

JavaScript
import { ToastrModule } from 'ngx-toastr';

and add this:

JavaScript
ToastrModule.forRoot(),

Into the listed imports in the body of the @ngModule code. The final app.module.ts will be:

JavaScript
import { NgModule, enableProdMode } from '@angular/core';
import { BrowserModule, Title } from '@angular/platform-browser';
import { routing, routedComponents } from './app.routing';
import { APP_BASE_HREF, Location } from '@angular/common';
import { AppComponent } from './app.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpModule  } from '@angular/http';
import { SampleDataService } from './services/sampleData.service';
import { AuthService } from './security/auth.service';
import { AuthGuard } from './security/auth-guard.service';
import { ToastrModule } from 'ngx-toastr';
import './rxjs-operators';

// enableProdMode();

@NgModule({
    imports: [BrowserModule, FormsModule, HttpModule, ToastrModule.forRoot(), routing],
    declarations: [AppComponent, routedComponents],
    providers: [SampleDataService,
        AuthService,
        AuthGuard, Title, { provide: APP_BASE_HREF, useValue: '/' }],
    bootstrap: [AppComponent]
})
export class AppModule { }

Next time you rebuild, the new package will be loaded (if it hasn’t already been fetched in the background by Visual Studio).

Adding a Table for Parent/Child Data View

In this article, we will replace the left hand bootstrap panel (used for data entry) on the AboutView with table. It won’t quite become a data grid, but certainly could be extended if you wish. The existing bootstrap panel on the right hand side of our AboutView will be changed to become a ‘child view’, allowing us to view a single item, add a new item as well as edit an item.

Since the new view will be quite modified, rather than replace things bit by bit and risk here HTML errors resulting in Angular zone errors, here is the completed source for the new AboutComponent.cshtml view:

HTML
@using A2SPA.ViewModels
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *,A2SPA
@model TestData

@{
    ViewData["Title"] = "About";
}
<div class="row">
    <div class="col-md-8">
        <h2>@ViewData["Title"].</h2>
        <h3>@ViewData["Message"]</h3>
        <p>Examples of Angular 2 data served by ASP.Net Core Web API:</p>
    </div>
    <div class="col-md-4">
        <div class="row pull-right text-right">
            <br />
            <p class="small">rowcount <span class="badge">
            {{ (testDataList == null) ? '0' : testDataList.length }}</span></p><br />
            <p class="small">mode 
            <span class="badge">{{tableMode}}</span></p><br />
        </div>
    </div>
</div>

<form #testForm="ngForm">
    <div class="row">
        <div *ngIf="testDataList == null || testDataList.length == 0" class="col-md-8">
            (no data)
        </div>
        <div *ngIf="testDataList != null && testDataList.length > 0" class="col-md-8">
            <table class="table table-hover">
                <thead>
                    <tr>
                        <th class="col-md-1" chfor="Id"> </th>
                        <th class="col-md-3" chfor="Username"> </th>
                        <th class="col-md-2" chfor="Currency"> </th>
                        <th class="col-md-3" chfor="EmailAddress"> </th>
                        <th class="col-md-2" chfor="Password"> </th>
                        <td><button type="button" class="btn btn-info btn-sm" 
                        (click)="testForm.reset();changeMode('add', null, $event)">+
                        </button></td>
                    </tr>
                </thead>
                <tbody>
                    <tr *ngFor="let item of testDataList" 
                    (click)="selectCurrentItem(item,$event)" 
                    [class.info]="item==selectedItem">
                        <td par="item" cdfor="Id"></td>
                        <td par="item" cdfor="Username"></td>
                        <td par="item" cdfor="Currency" pipe="| 
                         currency:'USD':true:'1.2-2'"></td>
                        <td par="item" cdfor="EmailAddress"></td>
                        <td par="item" cdfor="Password"></td>
                        <td>
                            <button type="button" class="btn btn-danger btn-sm" 
                            (click)="deleteRecord(item, $event)">X</button>
                            <button type="button" class="btn btn-info btn-sm" 
                            (click)="changeMode('edit', item, $event)">?</button>
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>
        <div class="col-md-4">
            <div class="row" [hidden]="!(tableMode==='add'||tableMode==='edit')" 
                             *ngIf="testData != null">
                <div class="panel panel-primary">
                    <div class="panel-heading">
                        <span [hidden]="!(tableMode==='add')">Add Data</span>
                        <span [hidden]="!(tableMode==='edit')">Edit Data</span>
                        <button type="button" class="btn btn-info btn-sm pull-right" 
                         (click)="tableMode='list'">-</button>
                    </div>
                    <div class="panel-body">
                        <tag-dd par="testData" for="Id"></tag-dd>
                        <tag-di par="testData" for="Username"></tag-di>
                        <tag-di par="testData" for="Currency"></tag-di>
                        <tag-di par="testData" for="EmailAddress"></tag-di>
                        <tag-di par="testData" for="Password"></tag-di>
                    </div>
                    <div class="panel-footer">
                        <button [disabled]="!testForm.form.valid" 
                        [hidden]="!(tableMode==='add')" type="button" 
                        class="btn btn-warning" (click)="addTestData($event)">
                        Save new item</button>
                        <button [disabled]="!testForm.form.valid" 
                        [hidden]="!(tableMode==='edit')" type="button" 
                        class="btn btn-warning" (click)="editTestData($event)">
                        Save updated item</button>
                        <button [hidden]="(tableMode==='list')" type="button" 
                        class="btn btn-info" (click)="testForm.reset();
                        tableMode='list';">Cancel</button>
                    </div>
                </div>
            </div>
            <div class="row" [hidden]="!(tableMode==='list')" *ngIf="selectedItem != null">
                <div class="panel panel-primary">
                    <div class="panel-heading">
                        Data Display
                        <button type="button" class="btn btn-info btn-sm pull-right" 
                        (click)="tableMode='list';">-</button>
                    </div>
                    <div class="panel-body">
                        <tag-dd par="selectedItem" for="Id"></tag-dd>
                        <tag-dd par="selectedItem" for="Username"></tag-dd>
                        <tag-dd par="selectedItem" for="Currency" pipe="| 
                         currency:'USD':true:'1.2-2'"></tag-dd>
                        <tag-dd par="selectedItem" for="EmailAddress"></tag-dd>
                        <tag-dd par="selectedItem" for="Password"></tag-dd>
                    </div>
                    <div class="panel-footer">
                        <button type="button" class="btn btn-info" 
                        (click)="getTestData()">Get all records from database</button>
                    </div>
                </div>
            </div>

        </div>
    </div>
</form>

<div *ngIf="errorMessage != null">
    <p>Error:</p>
    <pre>{{ errorMessage  }}</pre>
</div>

To prevent seeing a table with no data, and make it obvious there is no data, there is now a simple message displayed using an *ngIf statement:

HTML
<div *ngIf="testDataList == null || testDataList.length == 0" class="col-md-8">

    (no data)

</div>

<div *ngIf="testDataList != null && testDataList.length > 0" class="col-md-8">

    <table class="table table-hover">
...
</div>

In the second case, where there is data, the table is shown. As data is displayed in row form, each row has a click event attached using the Angular 2 (click) directive that calls our component and selects the item associated with that row. Here is an excerpt from about.component.ts that shows the new method that is called:

JavaScript
selectCurrentItem(thisItem: TestData, event: any) {
    event.preventDefault();
    this.selectedItem = thisItem;
    this.testData = Object.assign({}, thisItem);
}

Our onclick event populates two different copies of the data from the row; one copy is called selectedItem - and as the name suggests to track what is the selectedItem. It is copied using an = sign meaning that it’s identical with the array element from which it was copied. The second copy is made using a ‘deep copy’ using the ES2015 Object.assign() command (which will be handled using polyfills if not supported, or until it is supported), this command will create a copy that contains the same data but is not one with the source data

We’ll use this second copy for editing, otherwise edits would be visible in the data visible in the table, meaning we’d also need to provide undo methods to refresh the datagrid in case cancel our edit.

Note: If you decide to change this, you could deep copy the data and not have the selectedItem shallow copy, but then would need to provide an undo (else fetch the data again), or on the other hand when highlighting the row, you’d need to compare ids of the records, like this:

HTML
[class.info]="item.id==selectedItem.id"

Instead of the simpler option, by detecting equality as I have done here:

HTML
[class.info]="item==selectedItem"

When:

HTML
<tr *ngFor="let item of testDataList" (click)="selectCurrentItem(item,$event)" 
[class.info]="item==selectedItem">

Tag Helpers Refactored

For the “About page” to support both an HTML table as a ”parent view” and have a panel that will contain an editable form as a “child view”, we need to modify our tag helper so that we can control the naming. So far, the Angular variables used client side are prefixed using a convention, with the name of the class. The C# class TestData is “camelized” to testData and then used in conjunction with the name of each property.

We’re now going to edit data input and data display tag helpers to add support for custom prefixes to better control the Angular data binding variable names we generate.

Immediately under the public class … statement in each of TagDdTagHelper.cs and TagDiTagHelper.cs, please add the following:

C#
/// <summary>
/// Alternate name to set angular data-binding to
/// </summary>
[HtmlAttributeName("var")]
public string Var { get; set; } = null;

/// <summary>
/// Alternate name to set angular parent data-binding to
/// </summary>
[HtmlAttributeName("par")]
public string Par { get; set; } = null;

This will support two new optional attributes. The first to override the default data binding variable name, in case you do not want to conflict with another instance of the default, as the default is based on the property name.

HTML
var="variableName"

and the second optional attribute is to provide for a parent variable name, used in case we need to override the default, currently the class name:

HTML
par="parentName"

Currently, we’re using an extension method VariableNames.cs as shown below. It gave us a very simple variable name made of the class name and property name. This is going to be replaced.

C#
using Humanizer;
using Microsoft.AspNetCore.Mvc.ViewFeatures;

namespace A2SPA.Helpers
{
    public static class VariableNames
   {
        public static string CamelizedName(this ModelExpression modelExpression)
        {
            var className = modelExpression.Metadata.ContainerType.Name;
            var propertyName = modelExpression.Name;
 
            return className.Camelize() + "." + propertyName.Camelize();
        }
    }
}

First, rename the class name in the code above from VariableNames to TagHelperHelpers, then rename the file from VariableNames.cs to TagHelperHelpers.cs (for convention’s sake and to avoid confusion).

Replace the method “CamelizedName” method with the GetDataBindVariableName method, using the following code:

C#
/// <summary>
/// Create the angular binding variable name
/// </summary>
/// <remarks>
/// If 'par' (parent name) and 'var' (property name override)
/// are not supplied, then the name of the variable used for
/// angular data-binding is taken directly from the view model property name.
/// If parent (par) and override name (var) are both supplied
/// the angular data-bind variable is set to 'par.var'
/// If the 'var' is supplied and 'par' (parent) not supplied,
/// then only the 'var' is used
/// </remarks>
/// <param name="modelExpression">the model expression</param>
/// <param name="Par">optional parent name</param>
/// <param name="Var">optional property name, to override model property</param>
/// <returns></returns>
public static string GetDataBindVariableName
       (this ModelExpression modelExpression, string Par, string Var)
{
    var className = modelExpression.Metadata.ContainerType.Name;
    var propertyName = modelExpression.Name;

    var prefixName = string.IsNullOrEmpty(Par) ? className.Camelize() : Par;
    var varName = string.IsNullOrEmpty(Var) ? propertyName.Camelize() : Var;

    return string.Format("{0}.{1}", prefixName, varName);
}

Next in the data input tag helper class TagDiTagHelper.cs, we’ll need to update it, as we’ve removed the “camelized” name extension method. To create the correct databindings, change it from this:

C#
// bind angular data model to the control,
inputTag.MergeAttribute("[(ngModel)]", For.CamelizedName());

to:

C#
// bind angular data model to the control,
inputTag.MergeAttribute("[(ngModel)]", For.GetDataBindVariableName(Par, Var));

You’ll see we’re still using this as an extension method on the model property (which gives us access to the class name, property name and all metadata) but now with the addition of two new parameters (Par, Var), we can introduce our optional attributes for parent name and variable name.

Note: Normal convention dictates variables here would be camel-cased, I have retained the name as-is though you might also prefer to introduce either backing variables or assign to a local variable that is name more conventionally.

Next, change the corresponding code in the data display tag helper, TagDdTagHelper.cs where it has this:

C#
var dataBindExpression = ((DefaultModelMetadata)For.Metadata).DataTypeName == "Password"
                                     ? "******"
                                     : "{{" + For.CamelizedName() + pipe + "}}";

Change it to this:

C#
var dataBindExpression = ((DefaultModelMetadata)For.Metadata).DataTypeName == "Password"
                                     ? "******"
                                     : "{{" + For.GetDataBindVariableName(Par, Var) + 
                                     pipe + "}}"; 

We should now be able to build and view our new code. Login (or register then login) and you’ll see the parent / child view in action.

Image 1

Try clicking on a row, you’ll see the selectItem, then click the [?] icon and you’ll be able to edit an item.

To allow you to clear any changes, you can add a “Cancel” button, edit the file AboutComponent.cshtml to change the About view by inserting this new button just under the Add and Edit buttons.

HTML
<button [hidden]="(tableMode==='list')" type="button" class="btn btn-info"
        (click)="testForm.reset();tableMode='list';">Cancel</button>

There’s no need here to change our About component, as we’re using the built-in .reset() method to clear and reset the form. Without this, we’d end up with the form validation properties from one edit affecting another.

Next try saving some (obviously) bad data, say an empty password or username, either as a new item or edited item. There should be client side errors shown using the Angular/Bootstrap validation, but you should still be able to submit the bad data.

Image 2

You should see a similar toast showing something went wrong. This is the server-side validation in action. To make this server-side validation remain as a back up, and save round trips with bad data, we’ll block the buttons if there is any form validation error. Update the Add and Edit buttons to include a disabled directive:

HTML
<button [disabled]="!testForm.form.valid" [hidden]="!(tableMode==='add')" type="button"
         class="btn btn-warning" (click)="addTestData($event)">Save new item</button>

<button [disabled]="!testForm.form.valid" [hidden]="!(tableMode==='edit')" type="button"
         class="btn btn-warning" (click)="editTestData($event)">Save updated item</button>

Unlike the [hidden] or [required] directives which required changes to the stylesheet, [disabled] works out of the box.

Tag Helpers – Further Additions and Refactoring

The next issue that we’ll fix is to clean up the rendering of data in the table. Currently, all of the data is shown unformatted, or using the Angular defaults. Just as we created data display tag helper, we’ll now create two new tag helpers – one for the table header cells and another for the table data.

The table headers are currently repetitive and hard coded. If someone wanted to change the title of a column, then we’re going to potentially need to change this across a number of views. Create a new C# class file in the Helpers folder called TabCHTagHelper.cs and update it to contain the following:

C#
using Humanizer;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace A2SPA.Helpers
{
    /// <summary>
    /// Tag Helper for Table columns headers to display column name
    /// </summary>
    [HtmlTargetElement("th", Attributes=columnHeadingAttribute)]
    public class TabCHTagHelper : TagHelper
    {
        private const string columnHeadingAttribute = "chfor";

        /// <summary>
        /// Name of data property 
        /// </summary>
        [HtmlAttributeName(columnHeadingAttribute)]
        public ModelExpression ChFor { get; set; }

        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            var labelTag = ChFor.Metadata.PropertyName.Humanize();
            output.Content.AppendHtml(labelTag);
        }
    }
}

This tag helper is going to be different to the other taghelpers, instead of targeting an complete tag such as:

HTML
<tag-di 

Instead, we’ll be targeting an attribute. In this case, we’re also ensuring that the new attribute is only going to affect tags that are table headers, that is <th> tags.

In this tag helper class, I’ve created the heading name from a “humanized” form of model data property name. Camel cased properties such as “EmailAddress” will be converted to “Email Address”, that is to a short human-readable sentence with spaces separating the words.

Though quite simple, it now means any instance where we want a table heading, we’ll pick up the wording for this dynamically from our data model’s property name or if you wish to extend the naming, to include metadata, from the description, short description or even your own custom attributes.

To use our new tag helper, update the About view in AboutComponent.cshtml to change from this:

HTML
<tr>
    <th class="col-md-1"> Id </th>
    <th class="col-md-3"> Username </th>
    <th class="col-md-2"> Currency </th>
    <th class="col-md-3"> EmailAddress </th>
    <th class="col-md-2"> Password </th>
    <td><button type="button" class="btn btn-info btn-sm"
    (click)="testForm.reset();changeMode('add', null, $event)">+
    </button></td>
</tr>

to this:

HTML
<tr>
    <th class="col-md-1" chfor="Id"> </th>
    <th class="col-md-3" chfor="Username"> </th>
    <th class="col-md-2" chfor="Currency"> </th>
    <th class="col-md-3" chfor="EmailAddress"> </th>
    <th class="col-md-2" chfor="Password"> </th>
    <td><button type="button" class="btn btn-info btn-sm"
    (click)="testForm.reset();changeMode('add', null, $event)">+
    </button></td>
</tr>

Next, we’ll create one more tag helper to assist with generating our table. This will be used to display and format data in the table cells. Create another new class in the Helpers folder called TabCDTagHelper.cs and update it to this:

C#
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace A2SPA.Helpers
{
    /// <summary>
    /// Tag Helper for Table columns to data display
    /// </summary>
    [HtmlTargetElement("td", Attributes = columnDataAttribute)]
    public class TabCDTagHelper : TagHelper
    {
        private const string columnDataAttribute = "cdfor";

        /// <summary>
        /// Alternate name to set angular data-binding to
        /// </summary>
        [HtmlAttributeName("var")]
        public string Var { get; set; } = null;

        /// <summary>
        /// Alternate name to set angular parent data-binding to
        /// </summary>
        [HtmlAttributeName("par")]
        public string Par { get; set; } = null;

        /// <summary>
        /// Name of data property 
        /// </summary>
        [HtmlAttributeName(columnDataAttribute)]
        public ModelExpression CdFor { get; set; }

        /// <summary>
        /// Option: directly set display format using Angular 2 pipe and pipe format values
        /// </summary>
        ///<remarks>This attribute sets both pipe type and the pipe filter parameters.
        /// Numeric formats for decimal or percent in Angular 
        /// use a string with the following format: 
        /// a.b-c where:
        ///     a = minIntegerDigits is the minimum number of integer digits 
        ///     to use.Defaults to 1.
        ///     b = minFractionDigits is the minimum number of digits 
        ///     after fraction.Defaults to 0.
        ///     c = maxFractionDigits is the maximum number of digits 
        ///     after fraction.Defaults to 3.
        /// </remarks>
        /// <example>
        /// to format a decimal value as a percentage use "|percent" for the default Angular
        /// or for a custom percentage value eg. "| percent:'1:3-5' 
        /// </example>
        [HtmlAttributeName("pipe")]
        public string Pipe { get; set; } = null;

        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            var pipe = string.IsNullOrEmpty(Pipe) ? string.Empty : Pipe;
            var tagContents = CdFor.PopulateDataDisplayContents(pipe, Par, Var);
            output.Content.AppendHtml(tagContents);
        }
    }
}

So far, even though we’re only supporting a handful of data types in the tag helpers, already we have some further common content that generates the angular data binding code that should be refactored out to a common method.

In each of these tag helper classes, both TagDdTagHelper.cs and now in TabCDTagHelper.cs, this following code (give or take variable names) is repeated:

C#
var dataBindExpression = ((DefaultModelMetadata)For.Metadata).DataTypeName == "Password"
                                     ? "******"
                                     : "{{" + For.CamelizedName() + pipe + "}}";

            pTag.InnerHtml.Append(dataBindExpression);

Update the TagHelperHelpers.cs class to add a new method:

ASP.NET
/// <summary>
/// Returns a string populated with angular data binding expression to display data
/// </summary>
/// <param name="modelFor">data model as a ModelExpression</param>
/// <param name="pipe">pipe string, optional</param>
/// <param name="parentID">optional parent variable name,
///  overrides default data class name</param>
/// <param name="varName">optional variable name,
///  overrides default data property name</param>
/// <returns>string populated with Angular data binding expression,
///  and optional pipe if supplied</returns>
public static string PopulateDataDisplayContents
(this ModelExpression modelFor, string pipe, string parentID, string varName)
{
    string dataBindExpression =
           ((DefaultModelMetadata)modelFor.Metadata).DataTypeName == "Password"
                                        ? "******"
                                        : "{{" + modelFor.GetDataBindVariableName
                                        (parentID, varName) + pipe + "}}";

    return dataBindExpression;
}

Then in TagDdTagHelper.cs, replace this code:

C#
var dataBindExpression = ((DefaultModelMetadata)For.Metadata).DataTypeName == "Password"
                                     ? "******"
                                     : "{{" + For.CamelizedName() + pipe + "}}";

            pTag.InnerHtml.Append(dataBindExpression);

with the following:

C#
var tagContents = For.PopulateDataDisplayContents(pipe, Par, Var);
pTag.InnerHtml.Append(tagContents);

And in TabCDTagHelper.cs, replace this code:

C#
string tagContents = ((DefaultModelMetadata)modelFor.Metadata).DataTypeName == "Password"
                        ? "******"
                        : "{{" + CdFor.GetDataBindVariableName(Par, Var) + pipe + "}}";
output.Content.AppendHtml(tagContents);

with the following:

C#
var tagContents = CdFor.PopulateDataDisplayContents(pipe, Par, Var);
output.Content.AppendHtml(tagContents);

Now when we want to add say a datetimepicker, or some other custom control, we have one place to do our changes.

And finally, we should also be able to replace the plain hand-code markup in our about component view in AboutComponent.cshtml with our new <td> custom tag helper. Where you have this code:

HTML
<td>{{item.id}}</td>
<td>{{item.username}}</td>
<td>{{item.currency}}</td>
<td>{{item.emailAddress}}</td>
<td>{{item.password}}</td>

Replace it with this:

HTML
<td par="item" cdfor="Id"></td>
<td par="item" cdfor="Username"></td>
<td par="item" cdfor="Currency" pipe="| currency:'USD':true:'1.2-2'"></td>
<td par="item" cdfor="EmailAddress"></td>
<td par="item" cdfor="Password"></td>

Once again, rebuild and press Ctrl-F5, and you should see your new page complete.

For reference, the completed TagHelperHelpers.cs code is shown below:

C#
using Humanizer;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.AspNetCore.Mvc.ViewFeatures;

namespace A2SPA.Helpers
{
    public static class TagHelperHelpers
   {
        /// <summary>
        /// Create the angular binding variable name
        /// </summary>
        /// <remarks>
        /// If 'par' (parent name) and 'var' (property name override) are not supplied, 
        /// then the name of the variable used for 
        /// angular data-binding is taken directly from the view model property name.
        /// If parent (par) and override name (var) are both supplied 
        /// the angular data-bind variable is set to 'par.var'
        /// If the 'var' is supplied and 'par' (parent) not supplied, 
        /// then only the 'var' is used
        /// </remarks>
        /// <param name="modelExpression">the model expression</param>
        /// <param name="Par">optional parent name</param>
        /// <param name="Var">optional property name, to override model property</param>
        /// <returns></returns>
        public static string GetDataBindVariableName
               (this ModelExpression modelExpression, string Par, string Var)
        {
            var className = modelExpression.Metadata.ContainerType.Name;
            var propertyName = modelExpression.Name;

            var prefixName = string.IsNullOrEmpty(Par) ? className.Camelize() : Par;
            var varName = string.IsNullOrEmpty(Var) ? propertyName.Camelize() : Var;

            return string.Format("{0}.{1}", prefixName, varName);
        }

        /// <summary>
        /// Returns a string populated with angular data binding expression to display data
        /// </summary>
        /// <param name="modelFor">data model as a ModelExpression</param>
        /// <param name="pipe">pipe string, optional</param>
        /// <param name="parentID">optional parent variable name, 
        ///  overrides default data class name</param>
        /// <param name="varName">optional variable name, 
        ///  overrides default data property name</param>
        /// <returns>string populated with Angular data binding expression, 
        ///  and optional pipe if supplied</returns>
        public static string PopulateDataDisplayContents
        (this ModelExpression modelFor, string pipe, string parentID, string varName)
        {
            string dataBindExpression = 
                   ((DefaultModelMetadata)modelFor.Metadata).DataTypeName == "Password"
                                                ? "******"
                                                : "{{" + modelFor.GetDataBindVariableName
                                                (parentID, varName) + pipe + "}}";

            return dataBindExpression;
        }
    }
}

The completed AboutComponent.cshtml view is here:

HTML
@using A2SPA.ViewModels
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *,A2SPA
@model TestData

@{
    ViewData["Title"] = "About";
}
<div class="row">
    <div class="col-md-8">
        <h2>@ViewData["Title"].</h2>
        <h3>@ViewData["Message"]</h3>
        <p>Examples of Angular 2 data served by ASP.Net Core Web API:</p>
    </div>
    <div class="col-md-4">
        <div class="row pull-right text-right">
            <br />
            <p class="small">rowcount <span class="badge">
            {{ (testDataList == null) ? '0' : testDataList.length }}</span></p><br />
            <p class="small">mode <span class="badge">{{tableMode}}</span></p><br />
        </div>
    </div>
</div>

<form #testForm="ngForm">
    <div class="row">
        <div *ngIf="testDataList == null || testDataList.length == 0" class="col-md-8">
            (no data)
        </div>
        <div *ngIf="testDataList != null && testDataList.length > 0" class="col-md-8">
            <table class="table table-hover">
                <thead>
                    <tr>
                        <th class="col-md-1" chfor="Id"> </th>
                        <th class="col-md-3" chfor="Username"> </th>
                        <th class="col-md-2" chfor="Currency"> </th>
                        <th class="col-md-3" chfor="EmailAddress"> </th>
                        <th class="col-md-2" chfor="Password"> </th>
                        <td><button type="button" class="btn btn-info btn-sm" 
                        (click)="testForm.reset();changeMode('add', null, $event)">+
                        </button></td>
                    </tr>
                </thead>
                <tbody>
                    <tr *ngFor="let item of testDataList" 
                     (click)="selectCurrentItem(item,$event)" 
                     [class.info]="item==selectedItem">
                        <td par="item" cdfor="Id"></td>
                        <td par="item" cdfor="Username"></td>
                        <td par="item" cdfor="Currency" pipe="| 
                         currency:'USD':true:'1.2-2'"></td>
                        <td par="item" cdfor="EmailAddress"></td>
                        <td par="item" cdfor="Password"></td>
                        <td>
                            <button type="button" class="btn btn-danger btn-sm" 
                             (click)="deleteRecord(item, $event)">X</button>
                            <button type="button" class="btn btn-info btn-sm" 
                             (click)="changeMode('edit', item, $event)">?</button>
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>
        <div class="col-md-4">
            <div class="row" [hidden]="!(tableMode==='add'||tableMode==='edit')" 
             *ngIf="testData != null">
                <div class="panel panel-primary">
                    <div class="panel-heading">
                        <span [hidden]="!(tableMode==='add')">Add Data</span>
                        <span [hidden]="!(tableMode==='edit')">Edit Data</span>
                        <button type="button" class="btn btn-info btn-sm pull-right" 
                        (click)="tableMode='list'">-</button>
                    </div>
                    <div class="panel-body">
                        <tag-dd par="testData" for="Id"></tag-dd>
                        <tag-di par="testData" for="Username"></tag-di>
                        <tag-di par="testData" for="Currency"></tag-di>
                        <tag-di par="testData" for="EmailAddress"></tag-di>
                        <tag-di par="testData" for="Password"></tag-di>
                    </div>
                    <div class="panel-footer">
                        <button [disabled]="!testForm.form.valid" 
                        [hidden]="!(tableMode==='add')" type="button" 
                        class="btn btn-warning" (click)="addTestData($event)">
                        Save new item</button>
                        <button [disabled]="!testForm.form.valid" 
                        [hidden]="!(tableMode==='edit')" type="button" 
                        class="btn btn-warning" (click)="editTestData($event)">
                        Save updated item</button>
                        <button [hidden]="(tableMode==='list')" type="button" 
                        class="btn btn-info" (click)="testForm.reset();
                        tableMode='list';">Cancel</button>
                    </div>
                </div>
            </div>
            <div class="row" [hidden]="!(tableMode==='list')" *ngIf="selectedItem != null">
                <div class="panel panel-primary">
                    <div class="panel-heading">
                        Data Display
                        <button type="button" class="btn btn-info btn-sm pull-right" 
                        (click)="tableMode='list';">-</button>
                    </div>
                    <div class="panel-body">
                        <tag-dd par="selectedItem" for="Id"></tag-dd>
                        <tag-dd par="selectedItem" for="Username"></tag-dd>
                        <tag-dd par="selectedItem" for="Currency" pipe="| 
                         currency:'USD':true:'1.2-2'"></tag-dd>
                        <tag-dd par="selectedItem" for="EmailAddress"></tag-dd>
                        <tag-dd par="selectedItem" for="Password"></tag-dd>
                    </div>
                    <div class="panel-footer">
                        <button type="button" class="btn btn-info" 
                        (click)="getTestData()">Get all records from database</button>
                    </div>
                </div>
            </div>

        </div>
    </div>
</form>

<div *ngIf="errorMessage != null">
    <p>Error:</p>
    <pre>{{ errorMessage  }}</pre>
</div>

And the completed TagHelperHelpers.cs class is:

C#
using Humanizer;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.AspNetCore.Mvc.ViewFeatures;

namespace A2SPA.Helpers
{
    public static class TagHelperHelpers
   {
        /// <summary>
        /// Create the angular binding variable name
        /// </summary>
        /// <remarks>
        /// If 'par' (parent name) and 'var' (property name override) are not supplied, 
        /// then the name of the variable used for 
        /// angular data-binding is taken directly from the view model property name.
        /// If parent (par) and override name (var) are both supplied the 
        /// angular data-bind variable is set to 'par.var'
        /// If the 'var' is supplied and 'par' (parent) not supplied, 
        /// then only the 'var' is used
        /// </remarks>
        /// <param name="modelExpression">the model expression</param>
        /// <param name="Par">optional parent name</param>
        /// <param name="Var">optional property name, to override model property</param>
        /// <returns></returns>
        public static string GetDataBindVariableName
        (this ModelExpression modelExpression, string Par, string Var)
        {
            var className = modelExpression.Metadata.ContainerType.Name;
            var propertyName = modelExpression.Name;

            var prefixName = string.IsNullOrEmpty(Par) ? className.Camelize() : Par;
            var varName = string.IsNullOrEmpty(Var) ? propertyName.Camelize() : Var;

            return string.Format("{0}.{1}", prefixName, varName);
        }

        /// <summary>
        /// Returns a string populated with angular data binding expression to display data
        /// </summary>
        /// <param name="modelFor">data model as a ModelExpression</param>
        /// <param name="pipe">pipe string, optional</param>
        /// <param name="parentID">optional parent variable name, 
        ///  overrides default data class name</param>
        /// <param name="varName">optional variable name, 
        ///  overrides default data property name</param>
        /// <returns>string populated with Angular data binding expression, 
        ///  and optional pipe if supplied</returns>
        public static string PopulateDataDisplayContents
          (this ModelExpression modelFor, string pipe, string parentID, string varName)
        {
            string dataBindExpression = 
                   ((DefaultModelMetadata)modelFor.Metadata).DataTypeName == "Password"
                                                ? "******"
                                                : "{{" + modelFor.GetDataBindVariableName
                                                (parentID, varName) + pipe + "}}";

            return dataBindExpression;
        }
    }
}

If you need, you can also grab a copy from the repo using the source in Part 5 here on Github, or attached to this article.

Points of Interest

(Q) What did I learn that was interesting/fun/annoying while writing the code?

(A) I learnt again never underestimate the power of the simple mistake to throw you. A stray extra quote in an attribute <div class="xxx""> can really waste a lot of time.
Then zone.js gives obscure errors that can lead you in the wrong direction. If at first you don't succeed, check whether your HTML is clean!

I'd also really like to build a series of back end unit tests around the tag helpers, to ensure they generate clean code and generate the code I want, in isolation from the front end.
I can still see what is sent in the network tab - but I hate debugging, I'd rather test driven / test first. So, in getting this series out in a timely way, without being held up with creating further tests using early release test harnesses, and without extra code potentially complicating the series - well, this has been a further reinforcing of just how good it is doing TDD. IMO, it really saves time in the long run, and as far as I've seen, generates cleaner code.

(Q) What was particularly clever or wild or zany?

(A) I'm finding it more exciting as I get opportunities to generate more front end code from the back end data model code, gives much more extensible code with less pain. And future -proof (well as much as possible), easier refactoring around control changes.

History

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)