Click here to Skip to main content
15,065,324 members
Articles / Web Development / ASP.NET / ASP.NET Core
Article
Posted 19 Feb 2017

Stats

34K views
564 downloads
14 bookmarked

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

Rate me:
Please Sign up or sign in to vote.
5.00/5 (16 votes)
9 Mar 2017CPOL22 min read
How to keep your SPA code DRY! Why shouldn't everything be code first? Code-first database and code first code! ASP.NET Core tag helpers can kick start your SPA to create HTML and Angular 2 dynamically from your data model. Source now includes VS2015 and VS2017 versions.

Introduction

The aim of the articles in this series has been to show another way to integrate Angular 2 and ASP.NET Core.

In this part, we will add Entity Framework Core or EF Core, use "code first" to generate our database from our data model, and although we'll use a SQL backend, this was more for convenience and simplicity, so that focus can stay around Angular 2, ASP.NET Core and how to use your data model, along with MVC Partial Views with tag helpers, to generate code dynamically.

Along the way, I'll make a few mistakes, try and show a few of the pitfalls and potential issues, again centred around the integration of Angular 2 and ASP.NET Core, but won't be going into any great depth in Angular 2, ASP.NET Core MVC, nor tag helpers. There are plenty of good tutorials on this subject out there, though these other tutorials tend to deal with each of these separately.

Background

I've been using similar 'tricks' to this with JavaScript, Jquery and Angular 1.* alongside ASP, ASP.NET, and ASP.NET MVC for a number of years, but I am unaware of too many others doing this with Angular 2 yet.

A Pluralsight course by Matt Honeycutt has been published that uses similar concepts with MVC5 with Angular 1.x, though it doesn't extend to generating client side data models or services as we'll be doing soon, it could be useful if you are still using MVC5 and Angular 1.* - It certainly affirmed me in continuing what I had been doing.

Caveat: This is not quite production ready. I'm using a number of early release or beta components, including Angular 2 which is soon to morph into Angular 4. However, if your project time frame allows, I think this can provide you a great way to build your next SPA application.

Adding Entity Framework and SQL

We're going to add a couple of NuGet packages to our project that will provide database access. In Solution Explorer, highlight the A2SPA project, right click, select "Manage NuGet packages", browse for this package: Microsoft.EntityFrameworkCore.SqlServer.

Image 1

Select just the first, click install, part way into the installation, you will be asked to agree to some conditions.

Image 2

Next repeat the same process, browsing for this package: Microsoft.EntityFrameworkCore.Tools
Again select just this one package, click install, and accept conditions to proceed.

If you need assistance here, refer to this page.

Edit appsettings.json in the root folder of the project, add a connection strings section, changing it to:

JavaScript
{
  "ConnectionStrings": {
    "NorthwindConnection": "Server=(localdb)\\mssqllocaldb;Database=A2SPA;
     Trusted_Connection=True;MultipleActiveResultSets=true",
    "ApplicationDbConnection": "Server=(localdb)\\mssqllocaldb;Database=A2SPA;
     Trusted_Connection=True;MultipleActiveResultSets=true"
  },
  "Logging": {
    "IncludeScopes": false,
    "LogLevel": {
      "Default": "Debug",
      "System": "Information",
      "Microsoft": "Information"
    }
  }
}

At this point, we could create a folder that maps to our database's data schema, this data model or data classes that define the 'shape' of the database data, will often be separate from the view model - that is the data model (or data classes) that describe the data we see in our views.

Often, we need to start with an existing database, or work alongside a legacy application as we build our new application. In these cases, we'll be reading data from the database, through the data model, then "map" the data across to our view model which is used behind our Web API data services.

If you need to do this, you could find benefit from Jimmy Bogard's excellent AutoMapper library. This has been field tested over a number of years, and also uses convention over configuration to handle the mapping almost automatically in many instances, yet is extensible to provide support for custom type converters and many other helpful tools.

To keep this article focussed on Angular 2 and ASP.NET Core MVC, I'm assuming a simplified data model where the view model maps directly to the database.

We'll next create a data context class; first create a folder called Data at the root level of the project, then add a new class to this folder, call it A2spaContext.cs and update the content to the following:

C#
using A2SPA.ViewModels;
using Microsoft.EntityFrameworkCore;
 
namespace A2SPA.Data
{
    public class A2spaContext : DbContext
    {
        public A2spaContext(DbContextOptions<A2spaContext> options) : base(options)
        {
        }
 
        public DbSet<TestData> TestData { get; set; }
 
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<TestData>().ToTable("TestData");
        }
    }
}

Update the ConfigureServices method of Startup.cs to this:

JavaScript
public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<A2spaContext>(options =>
        options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
 
    // Add framework services.
    services.AddMvc();
}

And add the following dependencies to Startup.cs as well:

C#
using Microsoft.EntityFrameworkCore;
using A2SPA.Data;

We'll add an Id property to our TestData view model, so edit /ViewModels/TestData.cs to include this new property:

C#
[Display(Description = "Record #")]
 public int Id { get; set; }

Previously our Web API SampleDataController had just one working method, Get, and this was hard coded, as below. We'll lift the code from this for now:

C#
// GET: api/values
[HttpGet]
public TestData Get()
{
    var testData = new TestData
    {
        Username = "BillBloggs",
        EmailAddress = "bill.bloggs@example.com",
        Password = "P@55word",
        Currency = 123.45M
    };

    return testData;
}

to this:

C#
using System.Linq;
using A2SPA.ViewModels;
 
namespace A2SPA.Data
{
    public static class DbInitializer
    {
        public static void Initialize(A2spaContext context)
        {
            context.Database.EnsureCreated();
 
            // Look for any test data.
            if (context.TestData.Any())
            {
                return;   // DB has been seeded
            }
 
            var testData = new TestData
            {
                Username = "JaneDoe",
                EmailAddress = "jane.doe@example.com",
                Password = "LetM@In!",
                Currency = 321.45M
            };
 
            context.TestData.Add(testData);
            context.SaveChanges();
        }
    }
}

Now, we'll make our SampleDataController read from the SQL that is about to be created. Update SampleDataController.cs to this:

C#
using Microsoft.AspNetCore.Mvc;
using A2SPA.ViewModels;
using A2SPA.Data;
using System.Linq;
 
namespace A2SPA.Api
{
    [Route("api/[controller]")]
    public class SampleDataController : Controller
    {
        private readonly A2spaContext _context;
 
        public SampleDataController(A2spaContext context)
        {
            _context = context;
        }
        
        // GET: api/values
        [HttpGet]
        public TestData Get()
        {
            // pick up the last value, so we see something happening
            return _context.TestData.DefaultIfEmpty(null as TestData).LastOrDefault();
        }
 
        // POST api/values
        [HttpPost]
        public TestData Post([FromBody]TestData value)
        {
            // it's valid isn't it? ToDO: add server-side validation here
            value.Id = 0;
            var newTestData =_context.Add(value);
            _context.SaveChanges();
            return newTestData.Entity as TestData;
        }
 
        // PUT api/values/5
        [HttpPut("{id}")]
        public void Put(int id, [FromBody]TestData value)
        {
        }
 
        // DELETE api/values/5
        [HttpDelete("{id}")]
        public void Delete(int id)
        {
        }
    }
}

The code now provides a simple insert method that will write data to SQL (validation will be added later). We've also updated the Get method to return the last value in the database; this will show us something has happened without needing to add too much code server or client side until we understand where we're heading.

Later, we'll expand add validation to this SampleDataController, switch to async (asynchronous) methods, and provide delete, update and tidy up get to return a single record or all records.

Our back end changes are almost complete, we need to add a call to our startup.cs class so the new DbInitializer class is executed on application start-up. Update the Configure method in startup.cs to include a reference to our new database context:

C#
...
public void Configure(IApplicationBuilder app, IHostingEnvironment env, 
                      ILoggerFactory loggerFactory, A2spaContext context)
...

Then add this to the end of the Configure method in startup.cs, this will execute the initialize function only during development:

C#
...
    app.UseMvc(routes =>
    {
        routes.MapRoute(
            name: "default",
            template: "{controller=Home}/{action=Index}/{id?}");
 
        // in case multiple SPAs required.
        routes.MapSpaFallbackRoute("spa-fallback", 
               new { controller = "home", action = "index" });
 
    });
 
    if (env.IsDevelopment())
    {
        DbInitializer.Initialize(context);
    }
}
...

Next build and run the app, hit Ctrl-F5, you should see our About page as before, now with the new data from the data seeding:

Image 3

Use SQL Manager to look at our new table, again you can verify the code's operation:

Image 4


You can see the table has been initialized, and our sample data added.

Adding an insert method to our client side code.

So we're still only using the GET method of our SampleDataController, although it is at least using the database. Next, we'll update our client side code to allow us to try the new POST method to insert data.

Again, this will start off very simply, as the ultimate aim is to avoid any manual changes to client-side services or client-side data models by hand. We'll run against this ideal here, and start by doing a few simple alterations to our client side code. First the edit the data model /wwwroot/app/models/TestData.ts to add a row for the database id, change it to read:

JavaScript
import { Component } from '@angular/core';
 
export class TestData {
    id: number;
    username: string;
    currency: number;
    emailAddress: string;
    password: string;
}

Next, we need to update our client side data service, /wwwroot/app/services/SampleData.service.ts to add support for our new insert/post method, and to make some of the names a little more consistent, change it to read:

JavaScript
import { Injectable } from '@angular/core';
import { Http, Response, Headers } from '@angular/http';
import { Observable }     from 'rxjs/Observable';
import { TestData } from '../models/testData';
 
@Injectable()
export class SampleDataService {
 
    private url: string = 'api/sampleData';
 
    constructor(private http: Http) { }
 
    getSampleData(): Observable<TestData> {
        return this.http.get(this.url)
            .map((resp: Response) => resp.json())
            .catch(this.handleError);
    }
 
    addSampleData(testData: TestData): Observable<TestData> {
        let headers = new Headers({
            'Content-Type': 'application/json'
        });

        return this.http
            .post(this.url, JSON.stringify(testData), { headers: headers })
            .map((resp: Response) => resp.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);
    }
}

For some background reading into Angular 2 services, look at these tutorial pages:

Then we'll update the client side angular component around these changes, edit /wwwroot/app/about.component.ts to read the following:

JavaScript
import { Component, OnInit } from '@angular/core';
import { SampleDataService } from './services/sampleData.service';
import { TestData } from './models/testData';
 
@Component({
    selector: 'my-about',
    templateUrl: '/partial/aboutComponent'
})
 
export class AboutComponent implements OnInit {
    testData: TestData;
    errorMessage: string;
 
    constructor(private sampleDataService: SampleDataService) { }
 
    ngOnInit() {
        this.getTestData();
    }
 
    getTestData() {
        this.sampleDataService.getSampleData()
            .subscribe((data: TestData) => this.testData = data,
            error => this.errorMessage = <any>error);
    }
 
    addTestData(event: Event):void {
        event.preventDefault();
        if (!this.testData) { return; }
        this.sampleDataService.addSampleData(this.testData)
            .subscribe((data: TestData) => this.testData = data,
            error => this.errorMessage = <any>error);
    }
}

Notice we have an insert method that returns the newly saved data after we do the insert/post. This will help us see our changed data simply, without excess code overhead too soon.

Last of all, we'll update our view. Edit /Views/Partial/AboutComponent.cshtml.

If you lose track, or get issues, please grab the source, but since the file is lengthy, here are the places we need to update:

Change the last section of the view from this:

HTML
                        <div class="form-group">
                            <label for="password">Password</label>
                            <input type="password" id="password" name="password"
                                   required minlength="8" maxlength="16"
                                   class="form-control" placeholder="Password"
                                   [(ngModel)]="testData.password">
                        </div>
                    </div>
                </div>
            </div>
            <div class="col-md-6">
                <div class="panel panel-primary">
                    <div class="panel-heading">Data Display</div>
                    <div class="panel-body">
                        <tag-dd for="Username"></tag-dd>
 
                        <tag-dd for="Currency" pipe="| currency:'USD':true:'1.2-2'"></tag-dd>
 
                        <tag-dd for="EmailAddress"></tag-dd>
 
                        <tag-dd for="Password"></tag-dd>
                    </div>
                </div>
            </div>
        </div>
    </div>
</form>

We add two buttons, each inside a bootstrap panel footer, once to insert or 'post' data and the other to 'get' data.
To get some idea of the current record number, we'll also add a row for our record id:

HTML
                        <div class="form-group">
                            <label for="password">Password</label>
                            <input type="password" id="password" name="password"
                                   required minlength="8" maxlength="16"
                                   class="form-control" placeholder="Password"
                                   [(ngModel)]="testData.password">
                        </div>
                    </div>
                    <div class="panel-footer">
                        <button type="button" class="btn btn-warning" 
                        (click)="addTestData($event)">Save to database</button>
                    </div>
                </div>
            </div>
            <div class="col-md-6">
                <div class="panel panel-primary">
                    <div class="panel-heading">Data Display</div>
                    <div class="panel-body">
                        <tag-dd for="Id"></tag-dd>
 
                        <tag-dd for="Username"></tag-dd>
 
                        <tag-dd for="Currency" pipe="| 
                        currency:'USD':true:'1.2-2'"></tag-dd>
 
                        <tag-dd for="EmailAddress"></tag-dd>
 
                        <tag-dd for="Password"></tag-dd>
                    </div>
                    <div class="panel-footer">
                        <button type="button" class="btn btn-info" 
                        (click)="getTestData($event)">Get last record from database</button>
                    </div>
                </div>
            </div>
        </div>
    </div>
</form>

By now, you have noticed how easy it is to bring a data column into our view; sure ID is a little trivial, but having a tag helper and a convention for naming makes these changes much easier to manage.

Rebuild, press Ctrl-F5 and you should see a page something like before, still with the same 1 row of database data, though now with an ID on display, and some new buttons.

Image 5

The ID is display, as a simple "1" on the right hand panel, so let's fix that. Go to the server side view model, edit it changing it (note only part shown) from this:

C#
public class TestData
{
    public int Id { get; set; }
 
    [Display(Description = "Username", Name = "Username", Prompt = "Username")]
    public string Username { get; set; }

To add a description attribute to the ID column:

C#
public class TestData
{
    [Display(Description = "Record #")]
    public int Id { get; set; }
 
    [Display(Description = "Username", Name = "Username", Prompt = "Username")]
    public string Username { get; set; }

Now rebuild and refresh your browser, you should see this:

Image 6

Next, let's try out the new insert and get buttons. Edit the data using the left hand side edit text boxes, hit save. (The curious can watch using F12 and network to see the new insert data being posted to the server, then after the save the data returned from our web service including the new ID.

Note: Since left and right panes are linked, changes to the left appear in the right, yet after the post and insert, the ID changes since the data shown is now reflecting the newly inserted record.

This is prior to the edits:

Image 7

After clicking Save to Database, the ID will be updated showing the ID of the newly saved record:

Image 8

A look at the data, in the database, using SQL Manager and we can see our new record alongside the original seeded data record:

Image 9

Adding the Data Input Tag Helper

Now that we have a simple way to save data, before we complicate things with update, delete and our other operations, let's create a new tag helper.

Have a look at the HTML markup in the left hand panel of our view, /Views/Partial/AboutComponent.cshtml as we try and reduce this now obviously bloated section of the view.

There are four entries in the panel-body, each for a different property of the class we're displaying. Each will need a text box (instead of rendering a {{ }} for data binding as for the data display tag helper) and we'll need add various attributes including the datatype, id and name, as well as angular data binding. In addition, we've got data validation to add.

HTML
<div class="panel-body">
                       <div class="form-group">
                           <label for="username">Username</label>
                           <input type="text" id="username" name="username"
                                  required minlength="4" maxlength="24"
                                  class="form-control" placeholder="Username"
                                  [(ngModel)]="testData.username" #name="ngModel">
 
                           <div *ngIf="name.errors && (name.dirty || name.touched)"
                                class="alert alert-danger">
                               <div [hidden]="!name.errors.required">
                                   Name is required
                               </div>
                               <div [hidden]="!name.errors.minlength">
                                   Name must be at least 4 characters long.
                               </div>
                               <div [hidden]="!name.errors.maxlength">
                                   Name cannot be more than 24 characters long.
                               </div>
                           </div>
                       </div>
 
                       <div class="form-group">
                           <label for="currency">Payment Amount (in dollars)</label>
                           <div class="input-group">
                               <div class="input-group-addon">$</div>
                               <input type="number" id="currency" name="currency"
                                      required
                                      class="form-control" placeholder="Amount"
                                      [(ngModel)]="testData.currency" #currency="ngModel">
                           </div>
                           <div *ngIf="currency.errors && (currency.dirty || currency.touched)"
                                class="alert alert-danger">
                               <div [hidden]="!currency.errors.required">
                                   Payment Amount is required
                               </div>
                           </div>
                       </div>
                       <div class="form-group">
                           <label for="emailAddress">Email address</label>
                           <input type="email" id="emailAddress" name="emailAddress"
                                  required minlength="6" maxlength="80"
                                  pattern="([a-zA-Z0-9_\-\.]+)@@
                                  ((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|
                                  (([a-zA-Z0-9\-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})"
                                  class="form-control" placeholder="Email Address"
                                  [(ngModel)]="testData.emailAddress" #email="ngModel">
                           <div *ngIf="email.errors && (email.dirty || email.touched)"
                                class="alert alert-danger">
                               <div [hidden]="!email.errors.required">
                                   Email Address is required
                               </div>
                               <div [hidden]="!email.errors.pattern">
                                   Email Address is invalid
                               </div>
                               <div [hidden]="!email.errors.minlength">
                                   Email Address must be at least 6 characters long.
                               </div>
                               <div [hidden]="!email.errors.maxlength">
                                   Email Address cannot be more than 80 characters long.
                               </div>
                           </div>
                       </div>
                       <div class="form-group">
                           <label for="password">Password</label>
                           <input type="password" id="password" name="password"
                                  required minlength="8" maxlength="16"
                                  class="form-control" placeholder="Password"
                                  [(ngModel)]="testData.password">
                       </div>
                   </div>

To get started, we'll choose the simplest and smallest block of code, still considering what is happening in general in the other blocks, but trying to avoid the "YAGNI" trap - that is "You Ain't Gonna Need It". YAGNI is where you overbuild thinking you will need something and so many times spend time building something not needed in the end.

We'll learn from creating the tag helper to replace this first code block enough to get a start on the others soon after. So on to the password section at the end, which has this code now:

HTML
<div class="form-group">
      <label for="password">Password</label>
      <input type="password" id="password" name="password"
             required minlength="8" maxlength="16"
             class="form-control" placeholder="Password"
             [(ngModel)]="testData.password">
</div>

Which when rendered in the browser looks like this:

Image 10

Go to the /Helpers folder, create a new class file here called TagDiTagHelper.cs.

Decorate the class with the HtmlTargetElement attribute, and have it inherit from TagHelper, it should have:

C#
namespace A2SPA.Helpers
{
    [HtmlTargetElement("tag-di")]
 
    public class TagDiTagHelper : TagHelper
    {
    }
}

You'll need to add the following dependency to the using statements already there:

C#
using Microsoft.AspNet.Razor.TagHelpers;

Just as with our (still quite basic) data display tag helper, we need to create support for the view model's property, so add this inside the class:

C#
/// <summary>
/// Name of data property 
/// </summary>
[HtmlAttributeName("for")]
public ModelExpression For { get; set; }

This will require us to add another dependency:

C#
using Microsoft.AspNetCore.Mvc.ViewFeatures;

Next, we'll add a simple Process method inside the class to create the form group <div> tag, the label, and a plain input text box:

C#
public override void Process(TagHelperContext context, TagHelperOutput output)
{
    var labelTag = new TagBuilder("label");
    labelTag.InnerHtml.Append(For.Metadata.Description);
    labelTag.AddCssClass("control-label");
 
    var inputTag = new TagBuilder("input");
    inputTag.AddCssClass("form-control");
 
 
    output.TagName = "div";
    output.Attributes.Add("class", "form-group");
 
    output.Content.AppendHtml(labelTag);
    output.Content.AppendHtml(inputTag);
}

To satisfy the dependencies here, we should add this to our using statements:

C#
using Microsoft.AspNetCore.Mvc.Rendering;

Lastly, we'll use our new tag alongside the existing password input, so we update the partial view / angular template in the file /View/Partials/AboutComponent.cshtml, change the existing block for our password data entry to this:

HTML
<div class="form-group">
    <label for="password">Password</label>
    <input type="password" id="password" name="password"
        required minlength="8" maxlength="16"
        class="form-control" placeholder="Password"
        [(ngModel)]="testData.password">
 </div>

Save, rebuild and hit Ctrl-F5 … and we'll see what we've broken.

What Could Possibly Go Wrong?

If you do not get the tag rendering as above, check the F12 debug and see if you get an error. Sometimes a little cryptic:

Image 11

So perhaps, look at the network view instead, find the About Component, (refresh if you need, and make sure you capture the output, look at the response, and you might see what is happening:

Image 12

Check your source; if the new tag helper is not working, but the existing one is, then you need to go back to your tag helper. In the above case, I had the tag attribute incorrectly set to tag-dd not the new tag-di (yes, I copied my existing one and changed it slightly, but not enough … ouch).

After the fix, rebuild, try Ctrl-F5 again, and this time, we can see a couple of issues:

Image 13

At the bottom of the page, we see mismatched </div> and </form> tags. These are probably not really mismatched, but the real culprit is the added closing tag for the <input>, we need to fix this.

HTML input tags don't self-close, nor do they have closing tags.

Let's change our input tag to:

C#
var inputTag = new TagBuilder("input");
inputTag.AddCssClass("form-control");
inputTag.TagRenderMode = TagRenderMode.StartTag;

Rebuild, hit Ctrl-F5 and we should now see something more like an input textbox and label:

Image 14

Now we're getting closer:

HTML
<div class="form-group"><label class="control-label">
 Password</label><input class="form-control"></div>

We need to add a few missing attributes to our input tag, and also get the validation metadata for minlength and maxlength. So to save to much effort, add another new helper class to our \helpersdirectory, this time call it: FieldLengthValidation.cs.

Once created, edit this new class to add a few useful extension methods:

C#
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using System.ComponentModel.DataAnnotations;
using System.Linq;
 
namespace A2SPA.Helpers
{
    public static class FieldLengthValidation
    {
        /// <summary>
        /// Check if the data model has a minimum length attributes defined
        /// </summary>
        /// <param name="model">Model meta data</param>
        /// <returns>true if min length attribute set</returns>
        public static bool HasMinLengthValidation(this ModelMetadata model)
        {
            bool hashasMinLength = false;
 
            var validationItems = ((DefaultModelMetadata)model).
                                   ValidationMetadata.ValidatorMetadata;
            var hasStringValidationItems = validationItems.Any() && 
                validationItems.Any(a => (a as ValidationAttribute).GetType().
                ToString().Contains("StringLengthAttribute"));
            if (hasStringValidationItems)
            {
                hashasMinLength = MinLength(model) != null;
            }
 
            return hashasMinLength;
        }
        /// <summary>
        /// returns the minimum length from attributes of the data model
        /// </summary>
        /// <param name="model">Model meta data</param>
        /// <returns>minimum length as an int</returns>
        public static int? MinLength(this ModelMetadata model)
        {
            int? minLength = null;
            var validationItems = ((DefaultModelMetadata)model).
                                    ValidationMetadata.ValidatorMetadata;
 
            if (validationItems.Any())
            {
                var stringLengthValidation = validationItems.DefaultIfEmpty(null)
                                           .FirstOrDefault(a => (a as ValidationAttribute)
                                           .GetType().ToString()
                                           .Contains("StringLengthAttribute"));
                if (stringLengthValidation != null)
                {
                    minLength = 
                    (stringLengthValidation as StringLengthAttribute).MinimumLength;
                }
            }
 
            return minLength;
        }
 
        /// <summary>
        /// Check if the data model has a maximum length attributes defined
        /// </summary>
        /// <param name="model">Model meta data</param>
        /// <returns>true if max length attribute set</returns>
        public static bool HasMaxLengthValidation(this ModelMetadata model)
        {
            bool hasMaxLength = false;
 
            var validationItems = 
                ((DefaultModelMetadata)model).ValidationMetadata.ValidatorMetadata;
            var hasStringValidationItems = validationItems.Any() && 
                validationItems.Any(a => (a as ValidationAttribute).
                GetType().ToString().Contains("StringLengthAttribute"));
            if (hasStringValidationItems)
            {
                hasMaxLength = MaxLength(model) != null;
            }
 
            return hasMaxLength;
        }
 
        /// <summary>
        /// returns the maximum length from attributes of the data model
        /// </summary>
        /// <param name="model">Model meta data</param>
        /// <returns>maximum length as an int</returns>
        public static int? MaxLength(this ModelMetadata model)
        {
            int? maxLength = null;
            var validationItems = ((DefaultModelMetadata)model).
                                    ValidationMetadata.ValidatorMetadata;
 
            if (validationItems.Any())
            {
                var stringLengthValidation = validationItems.DefaultIfEmpty(null)
                                           .FirstOrDefault(a => (a as ValidationAttribute)
                                           .GetType().ToString().Contains
                                            ("StringLengthAttribute"));
                if (stringLengthValidation != null)
                {
                    maxLength = (stringLengthValidation as StringLengthAttribute).
                                 MaximumLength;
                }
            }
 
            return maxLength;
        }
    }
}

Now we'll update our data input tag helper. Looking at the client side code we need to generate, our server side tag helper will work outside in, creating the outer-most div, then the label tag, then the input tag and then putting the pieces together inside the outer div tag before completion. Alter the process method of the tag helper to this:

C#
public override void Process(TagHelperContext context, TagHelperOutput output)
{
    var labelTag = new TagBuilder("label");
    labelTag.InnerHtml.Append(For.Metadata.Description);
    labelTag.MergeAttribute("for", For.Name.Camelize());
    labelTag.AddCssClass("control-label");
 
    var inputTag = new TagBuilder("input");
    inputTag.AddCssClass("form-control");
    inputTag.MergeAttribute("type", "password");
    inputTag.MergeAttribute("id", For.Name.Camelize());
    inputTag.MergeAttribute("name", For.Name.Camelize());
    inputTag.MergeAttribute("placeholder", For.Metadata.Description);
 
    if (((DefaultModelMetadata)For.Metadata).HasMinLengthValidation())
    {
        inputTag.Attributes.Add("minLength", 
        ((DefaultModelMetadata)For.Metadata).MinLength().ToString());
    }
 
    if (((DefaultModelMetadata)For.Metadata).HasMaxLengthValidation())
    {
        inputTag.Attributes.Add("maxLength", 
        ((DefaultModelMetadata)For.Metadata).MaxLength().ToString());
    }
 
    if (((DefaultModelMetadata)For.Metadata).IsRequired)
    {
        inputTag.Attributes.Add("required", "required");
    }
 
    inputTag.MergeAttribute("[(ngModel)]", For.CamelizedName());
    inputTag.TagRenderMode = TagRenderMode.StartTag;
 
    output.TagName = "div";
    output.Attributes.Add("class", "form-group");
 
    output.Content.AppendHtml(labelTag);
    output.Content.AppendHtml(inputTag);
}

And last of all, add two new dependencies to the data input tag helper using statements:

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

Again rebuild and hit Ctrl-F5.

If you examine the source, you should see the original block of code:

HTML
<div class="form-group">
                            <label for="password">Password</label>
                            <input type="password" id="password" name="password"
                                   required minlength="8" maxlength="16"
                                   class="form-control" placeholder="Password"
                                   [(ngModel)]="testData.password">
                        </div>

Remember the new tag helper that replaces this is just the following:

HTML
<tag-di for="Password"></tag-di>

In your browser, again press F12, where you can compare this original block of code to our code generated from the tag helper. There are now no extra carriage returns. There is an additional value to the required attribute; now we have required="required" instead of simply required.

This is not really important, it's one of those optional pieces of HTML markup, that can be fiddly to get rid of in our tag helper as it expect a parameter, so for the sake of simplicity, we'll leave this as is.

HTML
<div class="form-group"><label class="control-label" for="password">
 Password</label><input class="form-control" id="password" maxLength="100"
 minLength="6" name="password" placeholder="Password" required="required"
 type="password" [(ngModel)]="testData.password"></div>

Now we can remove the original password block and start to tackle the other data types.

Extending the Data Input Tag Helper

We've managed to replace our simplest code block, the password section. Next to another data type, the currency entry is next largest. As it is now, in the browser, the client side "sees" this:

HTML
<div class="form-group">
    <label for="currency">Payment Amount (in dollars)</label>
    <div class="input-group">
        <div class="input-group-addon">$</div>
        <input type="number" id="currency" name="currency"
               required
               class="form-control" placeholder="Amount"
               [(ngModel)]="testData.currency" #currency="ngModel">
    </div>
    <div *ngIf="currency.errors && (currency.dirty || currency.touched)"
         class="alert alert-danger">
        <div [hidden]="!currency.errors.required">
            Payment Amount is required
        </div>
    </div>
</div>

Structurally it is similar, there's an outer div, a label, but this differs in that bootstrap markup for the currency text box includes some styling, and then there's a code block that handles validation, with an error message if the user doesn't enter a username.

We could use if/then code blocks, but once we add other data types, we'll need to change this, so we'll begin by wrapping our internal code inside a case statement so that we can handle different data types:

Add this to the beginning of process method so that we can pick up the datatype:

C#
var dataType = ((DefaultModelMetadata)For.Metadata).DataTypeName;

Midway through creating the input tag, we'll wrap the line that adds the type attribute with our switch/case statement:

C#
switch (dataType)
{
    case "Password":
        inputTag.MergeAttribute("type", dataType);
        break;
 
    case "Currency":
        inputTag.MergeAttribute("type", "number");
        break;
 
    default:
        inputTag.MergeAttribute("type", "text");
        break;
}

Next, we'll add our tag helper markup into the view, /Views/Partial/AboutComponent.cshtml as we did before for password, this time just below our existing currency markup:

HTML
<div class="form-group">
    <label for="currency">Payment Amount (in dollars)</label>
    <div class="input-group">
        <div class="input-group-addon">$</div>
        <input type="number" id="currency" name="currency"
               required
               class="form-control" placeholder="Amount"
               [(ngModel)]="testData.currency" #currency="ngModel">
    </div>
    <div *ngIf="currency.errors && (currency.dirty || currency.touched)"
         class="alert alert-danger">
        <div [hidden]="!currency.errors.required">
            Payment Amount is required
        </div>
    </div>
</div>
 
<tag-di for="Currency"></tag-di>

Before we go further, rebuild, press Ctrl-F5 and re-check:

Image 15

So far, we've managed to pick up the description for our label, and many other attributes and values have been using convention over configuration, and "just happen" to coincide with what we need. We've mapped our currency and password fields from the C# data model's data type attribute to the appropriate HTML type attribute, and so far, for password and currency, these map directly. Later, we will need to handle different mappings when things don't line up as they do here, and as a default, we've added a simple fall back to "text". More than this and we get into the YAGNI area, we might not need it, so leave it out for now until (or if) we do need it.

Next we'll add our bootstrap input group, this needs to be done in place of the plain append at the end of the method, as it consists of a div tag surrounding the input tag. So replace this line:

C#
output.Content.AppendHtml(inputTag);

with this line:

HTML
switch (dataType)
{
    case "Currency":
        var divInputGroup = new TagBuilder("div");
        divInputGroup.MergeAttribute("class", "input-group");
        var spanInputGroupAddon = new TagBuilder("span");
        spanInputGroupAddon.MergeAttribute("class", "input-group-addon");
        spanInputGroupAddon.InnerHtml.Append("$");
        divInputGroup.InnerHtml.AppendHtml(spanInputGroupAddon);
        divInputGroup.InnerHtml.AppendHtml(inputTag);
        output.Content.AppendHtml(divInputGroup);
        break;
 
    default:
        output.Content.AppendHtml(inputTag);
        break;
}

Again rebuild, hit Ctrl-F5 and check our progress:

Image 16

Next, we need to add the validation messages. Here again, we want to automate these, not have to do them over and over by hand or by special exceptions. So if our data model carries the metadata, we'll use this to generate validation where it is needed.

Our validation, if it is present, will be wrapped in a simple div that has angular markup built in, to only warn people when there is a change to the form. You might want to change this, some prefer a text box that is initially empty but required to start showing that it is invalid, on page load. My preference has been (as shown here) validation after the users start altering the text, or entering the text.

The original markup is:

HTML
<div *ngIf="currency.errors && (currency.dirty || currency.touched)"
     class="alert alert-danger">
    <div [hidden]="!currency.errors.required">
        Payment Amount is required
    </div>
</div>

Presence of this will be on the basis of their being a required attribute in the data model. We use this now, to add required="required", and so we could use this check if we need to add the internal div.

Later, we may need to refactor this as we strike cases that have min length or max length, but say do not have required, but for now, let’s try and do the least practical amount of code to make it work. So for now, here is the complete tag helper for this next step:

C#
using Humanizer;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
 
namespace A2SPA.Helpers
{
    [HtmlTargetElement("tag-di")]
    public class TagDiTagHelper : TagHelper
    {
        /// <summary>
        /// Name of data property 
        /// </summary>
        [HtmlAttributeName("for")]
        public ModelExpression For { get; set; }
 
 
        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            var dataType = ((DefaultModelMetadata)For.Metadata).DataTypeName;
 
            var labelTag = new TagBuilder("label");
            labelTag.InnerHtml.Append(For.Metadata.Description);
            labelTag.MergeAttribute("for", For.Name.Camelize());
            labelTag.AddCssClass("control-label");
 
            var inputTag = new TagBuilder("input");
            inputTag.AddCssClass("form-control");
 
            switch (dataType)
            {
                case "Password":
                    inputTag.MergeAttribute("type", dataType);
                    break;
 
                case "Currency":
                    inputTag.MergeAttribute("type", "number");
                    break;
 
                default:
                    inputTag.MergeAttribute("type", "text");
                    break;
            }
 
            inputTag.MergeAttribute("id", For.Name.Camelize());
            inputTag.MergeAttribute("name", For.Name.Camelize());
            inputTag.MergeAttribute("placeholder", For.Metadata.Description);
            inputTag.MergeAttribute("#" + For.Name.Camelize(), "ngModel");
 
            TagBuilder validationBlock = new TagBuilder("div");
            validationBlock.MergeAttribute("*ngIf", string.Format("{0}.errors", 
                                            For.Name.Camelize()));
            validationBlock.MergeAttribute("class", "alert alert-danger");
 
            if (((DefaultModelMetadata)For.Metadata).HasMinLengthValidation())
            {
                inputTag.Attributes.Add("minLength", 
                ((DefaultModelMetadata)For.Metadata).MinLength().ToString());
            }
 
            if (((DefaultModelMetadata)For.Metadata).HasMaxLengthValidation())
            {
                inputTag.Attributes.Add("maxLength", 
                ((DefaultModelMetadata)For.Metadata).MaxLength().ToString());
            }
 
            if (((DefaultModelMetadata)For.Metadata).IsRequired)
            {
                var requiredValidation = new TagBuilder("div");
                requiredValidation.MergeAttribute("[hidden]", 
                string.Format("!{0}.errors.required", For.Name.Camelize()));
                requiredValidation.InnerHtml.Append
                (string.Format("{0} is required", For.Metadata.Description));
                validationBlock.InnerHtml.AppendHtml(requiredValidation);
                inputTag.Attributes.Add("required", "required");
            }
 
            inputTag.MergeAttribute("[(ngModel)]", For.CamelizedName());
            inputTag.TagRenderMode = TagRenderMode.StartTag;
 
            output.TagName = "div";
            output.Attributes.Add("class", "form-group");
 
            output.Content.AppendHtml(labelTag);
 
            // wrap the input tag with an input group, if needed
            switch (dataType)
            {
                case "Currency":
                    var divInputGroup = new TagBuilder("div");
                    divInputGroup.MergeAttribute("class", "input-group");
                    var spanInputGroupAddon = new TagBuilder("span");
                    spanInputGroupAddon.MergeAttribute("class", "input-group-addon");
                    spanInputGroupAddon.InnerHtml.Append("$");
                    divInputGroup.InnerHtml.AppendHtml(spanInputGroupAddon);
                    divInputGroup.InnerHtml.AppendHtml(inputTag);
                    output.Content.AppendHtml(divInputGroup);
                    break;
 
                default:
                    output.Content.AppendHtml(inputTag);
                    break;
            }
 
            output.Content.AppendHtml(validationBlock);
        }
    }
}

You'll see we've added a validation block, all of the time, and not just when needed (we'll fix this soon) .

This will get us closer to what we need, build hit Ctrl-F5 and we'll see what we need to do next. We are at getting two very similar input text boxes:

Image 17

Delete the contents, to make the validation kick-in, and then we see:

Image 18

Close, but the length Description from the TestData property is getting re-used everywhere, and is not the same as before. Let's fix this by adding some further metadata entries as we have for some of the other properties in the class. Update TestData.cs to:

C#
[Display(Description = "Payment Amount (in dollars)", 
 Name = "Amount", Prompt = "Payment Amount")]
[DataType(DataType.Currency)]
public decimal Currency { get; set; }

Then we'll add a couple of methods to pick MetaData name, prompt or description - if available, or fall back to the property name if not set.

C#
var shortLabelName = ((DefaultModelMetadata)For.Metadata).DisplayName ?? For.Name.Humanize();
var labelName = ((DefaultModelMetadata)For.Metadata).Placeholder ?? shortLabelName;
var description = For.Metadata.Description ?? labelName;

Then alter the references in the process method throughout to include the appropriate label, prompt or description.

(Or refer to the compete source a little later.)

Once completed, our markup is quite close:

Image 19

and:

Image 20

Delete the old markup from the view, and leave our new tag helper in place. That is go from:

HTML
<div class="form-group">
    <label for="currency">Payment Amount (in dollars)</label>
    <div class="input-group">
        <div class="input-group-addon">$</div>
        <input type="number" id="currency" name="currency"
               required
               class="form-control" placeholder="Amount"
               [(ngModel)]="testData.currency" #currency="ngModel">
    </div>
    <div *ngIf="currency.errors && (currency.dirty || currency.touched)"
         class="alert alert-danger">
        <div [hidden]="!currency.errors.required">
            Payment Amount is required
        </div>
    </div>
</div>
 
<tag-di for="Currency"></tag-di>

To just:

HTML
<tag-di for="Currency"></tag-di>

Next, we'll cover the username, and give it the conversion treatment. Again, add a new tag-helper tag under the original code block, so that you have this:

HTML
<div class="form-group">
    <label for="username">Username</label>
    <input type="text" id="username" name="username"
           required minlength="4" maxlength="24"
           class="form-control" placeholder="Username"
           [(ngModel)]="testData.username" #name="ngModel">
 
    <div *ngIf="name.errors && (name.dirty || name.touched)"
         class="alert alert-danger">
        <div [hidden]="!name.errors.required">
            Name is required
        </div>
        <div [hidden]="!name.errors.minlength">
            Name must be at least 4 characters long.
        </div>
        <div [hidden]="!name.errors.maxlength">
            Name cannot be more than 24 characters long.
        </div>
    </div>
</div>
 
<tag-di for="Username"></tag-di>

We won't need to add support for the datatype since we added a default earlier to the case statement to default to type="text". So let's see how close we are already, rebuild and press Ctrl-F5. Check your browser:

Image 21

Some of the bootstrap validation is missing, check our datamodel (which would normally be done early in a project, but here we're using these missing features as an aid to the article).

C#
[Display(Description = "Username", Name = "Username", Prompt = "Username")]
public string Username { get; set; }

We’re missing the required attribute. And we're also missing the minlength and maxlength; present in the original hand-done client-side validation, but also missing from the data model. Alter TestData.cs to include this instead:

C#
[Required]
[StringLength(24, MinimumLength = 4)]
[Display(Description = "Username", Name = "Username", Prompt = "Username")]
public string Username { get; set; }

Now once again rebuild and hit Ctrl-F5, then check the browser. We'll see this is closer:

Image 22

Now test out the validation, removing the text:

Image 23

The original was Name vs what we have now, Username; we could override this using the meta data, but username sounds more descriptive and closer to what we want. So we'll leave this. Next check the length validation, add 1 or 2 characters, something less than the minimum of 4.

Image 24

We need to add validation message blocks into our existing minlength and maxlength, as we did for required a little earlier.

C#
if (((DefaultModelMetadata)For.Metadata).HasMinLengthValidation())
{
    var minLength = ((DefaultModelMetadata)For.Metadata).MinLength();
    var minLengthValidation = new TagBuilder("div");
    minLengthValidation.MergeAttribute("[hidden]", 
    string.Format("!{0}.errors.minlength", For.Name.Camelize()));
    minLengthValidation.InnerHtml.Append(string.Format("{0} 
    must be at least {1} characters long", labelName, minLength));
    validationBlock.InnerHtml.AppendHtml(minLengthValidation);
    inputTag.Attributes.Add("minLength", minLength.ToString());
}
 
if (((DefaultModelMetadata)For.Metadata).HasMaxLengthValidation())
{
    var maxLength = ((DefaultModelMetadata)For.Metadata).MaxLength();
    var maxLengthValidation = new TagBuilder("div");
    maxLengthValidation.MergeAttribute("[hidden]", 
    string.Format("!{0}.errors.maxlength", For.Name.Camelize()));
    maxLengthValidation.InnerHtml.Append(string.Format
    ("{0} cannot be more than {1} characters long", labelName, maxLength));
    validationBlock.InnerHtml.AppendHtml(maxLengthValidation);
    inputTag.Attributes.Add("maxLength", maxLength.ToString());
}

Notice we've extracted the minlength and maxlength values into local variables as we use these in more than one place. Once again rebuild, press Ctrl-F5 and voila:

Image 25

Once again, now this works, we'll delete the original markup from our view and just leave the new tag-helper tag in place. By now, we should only have the email data input left to convert, it should be:

HTML
<div class="panel-body">
 
    <tag-di for="Username"></tag-di>
    <tag-di for="Currency"></tag-di>
 
    <div class="form-group">
        <label for="emailAddress">Email address</label>
        <input type="email" id="emailAddress" name="emailAddress"
               required minlength="6" maxlength="80"
               pattern="([a-zA-Z0-9_\-\.]+)@@
                        ((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|
                        (([a-zA-Z0-9\-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})"
               class="form-control" placeholder="Email Address"
               [(ngModel)]="testData.emailAddress" #email="ngModel">
        <div *ngIf="email.errors && (email.dirty || email.touched)"
             class="alert alert-danger">
            <div [hidden]="!email.errors.required">
                Email Address is required
            </div>
            <div [hidden]="!email.errors.pattern">
                Email Address is invalid
            </div>
            <div [hidden]="!email.errors.minlength">
                Email Address must be at least 6 characters long.
            </div>
            <div [hidden]="!email.errors.maxlength">
                Email Address cannot be more than 80 characters long.
            </div>
        </div>
    </div>
 
    <tag-di for="Password"></tag-di>
</div>

Just as before, add a new tag-helper tag in, again below the hand-coded block and this time just above password field.

HTML
<tag-di for="EmailAddress"></tag-di>

And again, rebuild and hit Ctrl-F5 to see how close we are. We certainly don't have regular expressions yet, as we've not yet added this feature, and the other difference is we don't yet pick up type="email", so it will default to type="text".

Image 26

Close but no cigar. We've got the wrong name for the label, check the data model, or view model, TestData.cs and we see:

C#
[Required, RegularExpression(@"([a-zA-Z0-9_\-\.]+)@
((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([a-zA-Z0-9\-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})", 
ErrorMessage = "Please enter a valid email address.")]
[EmailAddress]
[Display(Description = "Username", Name = "EmailAddress", ShortName = "Email", 
 Prompt = "Email Address")]
[DataType(DataType.EmailAddress)]
public string EmailAddress { get; set; }

And can see immediately a typo, Description="Username". Change this to Description="Email address", rebuild and we're back to what we should have:

Image 27

Next let's check the validation, removing a few characters to cause the regular expression validation to fail us, and we get:

Image 28

BTW, the reason for the empty red box is we're currently running the same names for the control, so we're failing two for the price of one, and as they're both using Angular two way data binding, it gives us a quick way to test the code. It would be better to run completely independently (and we’ll add some ways to allow this later, with further overrides and options). But for now, we're keeping it as simple as possible.

Let's add regular expression validation next. We'll add a helper class to save us some repetition later, go to the \helpers folder and a new class called RegularExpressionValidation, then edit the new file RegularExpressionValidation.cs just created to include this:

C#
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using System.ComponentModel.DataAnnotations;
using System.Linq;
 
namespace A2SPA.Helpers
{
    public static class RegularExpressionValidation
    {
        /// <summary>
        /// Check if the underlying data model has a regular expression 
        /// validation expression attribute defined
        /// </summary>
        /// <param name="model">Model meta data</param>
        /// <returns>true if regex attribute set</returns>
        public static bool HasRegexValidation(this ModelMetadata model)
        {
            bool hasRegex = false;
 
            var items = ((DefaultModelMetadata)model).ValidationMetadata.ValidatorMetadata;
            hasRegex = items.Any() && items.Any(a => 
            (a as ValidationAttribute).GetType().ToString().Contains
            ("RegularExpressionAttribute"));
 
            return hasRegex;
        }
        /// <summary>
        /// returns the regular expression set in the attributes of the data model
        /// </summary>
        /// <param name="model">Model meta data</param>
        /// <returns>regex expression as a string</returns>
        public static string RegexExpression(this ModelMetadata model)
        {
            string regex = string.Empty;
            var items = ((DefaultModelMetadata)model).ValidationMetadata.ValidatorMetadata;
            if (items.Any())
            {
                var regexExpression = items.DefaultIfEmpty(null).FirstOrDefault(a => 
                (a as ValidationAttribute).GetType().ToString().Contains
                ("RegularExpressionAttribute"));
                if (regexExpression != null)
                {
                    regex = (regexExpression as RegularExpressionAttribute).Pattern;
                }
            }
 
 
            return regex;
        }
    }
}

Return back to our data input tag helper, and (though not vitally important) say below the "maxlength" section of code and above the "required" section of code, we can add this:

C#
if (((DefaultModelMetadata)For.Metadata).HasRegexValidation())
{
    var regexValidation = new TagBuilder("div");
    regexValidation.MergeAttribute("[hidden]", string.Format("!{0}.errors.pattern", 
                                    For.Name.Camelize()));
    regexValidation.InnerHtml.Append(string.Format("{0} is invalid", labelName));
    validationBlock.InnerHtml.AppendHtml(regexValidation);
    inputTag.Attributes.Add("pattern", ((DefaultModelMetadata)For.Metadata).RegexExpression());
}

And once again rebuild, hit Ctrl-F5 and recheck:

Image 29

Next let's check if the min length validation works (which we know will fail to work properly, since it's not in the data model metadata yet).

Image 30

Add an extra attribute to the email address property of the TestData class, as below:

C#
[StringLength(80, MinimumLength = 6)]

Rebuild and test again:

Image 31

And lastly, check the required field for the email address:

Image 32

All good. Time for a little refactoring and code clean up. Look through the code and you will see a number of repetitions, our metadata and property name:

C#
var metadata = ((DefaultModelMetadata)For.Metadata);
var propertyName = For.Name.Camelize();

So that our final data input tag helper class becomes this:

NOTE / fine print: The following copy of TagDiTagHelper.cs contains a few extra comments, and is not production code - it should be refactored to break out common code blocks, and allow extensibility. For clarity, there are only a few data types supported here. The code is shown below in a top down manner to indicate visibly how you might create the required tags, not how you should.
C#
using Humanizer;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
 
namespace A2SPA.Helpers
{
    [HtmlTargetElement("tag-di")]
    public class TagDiTagHelper : TagHelper
    {
        /// <summary>
        /// Name of data property 
        /// </summary>
        [HtmlAttributeName("for")]
        public ModelExpression For { get; set; }
 
        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            // get metadata names, property name and data type
            var metadata = ((DefaultModelMetadata)For.Metadata);
            var propertyName = For.Name.Camelize();
            var dataType = metadata.DataTypeName;
 
            // find best fit for labels and descriptions
            var shortLabelName = metadata.DisplayName ?? this.For.Name.Humanize();
            var labelName = metadata.Placeholder ?? shortLabelName;
            var description = For.Metadata.Description ?? labelName;
 
            // generate the label, point to the data entry input control
            var labelTag = new TagBuilder("label");
            labelTag.InnerHtml.Append(description);
            labelTag.MergeAttribute("for", propertyName);
            labelTag.AddCssClass("control-label");
 
            // add the input control; TODO: add textarea, date picker support
            var inputTag = new TagBuilder("input");
            inputTag.AddCssClass("form-control");
 
            // TODO: further expand datatypes here
            switch (dataType)
            {
                case "Password":
                    inputTag.MergeAttribute("type", dataType);
                    break;
 
                case "Currency":
                    inputTag.MergeAttribute("type", "number");
                    break;
 
                default:
                    inputTag.MergeAttribute("type", "text");
                    break;
            }
 
            // common attributes for data input control here
            inputTag.MergeAttribute("id", propertyName);
            inputTag.MergeAttribute("name", propertyName);
            inputTag.MergeAttribute("placeholder", shortLabelName);
            inputTag.MergeAttribute("#" + propertyName, "ngModel");
 
            // set up validation conditional DIV here; only show error 
            // if an error in data entry
            TagBuilder validationBlock = new TagBuilder("div");
            validationBlock.MergeAttribute
                ("*ngIf", string.Format("{0}.errors", propertyName));
            validationBlock.MergeAttribute("class", "alert alert-danger");
 
            // Handle minimum, maximum, required, regex and other validation. 
            // TODO: refactor common code out
            if (metadata.HasMinLengthValidation())
            {
                var minLength = metadata.MinLength();
                var minLengthValidation = new TagBuilder("div");
                minLengthValidation.MergeAttribute("[hidden]", 
                         string.Format("!{0}.errors.minlength", propertyName));
                minLengthValidation.InnerHtml.Append
                (string.Format("{0} must be at least {1} 
                characters long", labelName, minLength));
                validationBlock.InnerHtml.AppendHtml(minLengthValidation);
 
                inputTag.Attributes.Add("minLength", minLength.ToString());
            }
 
            if (metadata.HasMaxLengthValidation())
            {
                var maxLength = metadata.MaxLength();
                var maxLengthValidation = new TagBuilder("div");
                maxLengthValidation.MergeAttribute("[hidden]", 
                string.Format("!{0}.errors.maxlength", propertyName));
                maxLengthValidation.InnerHtml.Append
                (string.Format("{0} cannot be more than {1} characters long", 
                labelName, maxLength));
                validationBlock.InnerHtml.AppendHtml(maxLengthValidation);
 
                inputTag.Attributes.Add("maxLength", maxLength.ToString());
            }
 
            if (metadata.HasRegexValidation())
            {
                var regexValidation = new TagBuilder("div");
                regexValidation.MergeAttribute("[hidden]", 
                string.Format("!{0}.errors.pattern", propertyName));
                regexValidation.InnerHtml.Append(string.Format("{0} is invalid", labelName));
                validationBlock.InnerHtml.AppendHtml(regexValidation);
 
                inputTag.Attributes.Add("pattern", metadata.RegexExpression());
            }
 
            if (metadata.IsRequired)
            {
                var requiredValidation = new TagBuilder("div");
                requiredValidation.MergeAttribute("[hidden]", 
                string.Format("!{0}.errors.required", propertyName));
                requiredValidation.InnerHtml.Append
                (string.Format("{0} is required", labelName));
                validationBlock.InnerHtml.AppendHtml(requiredValidation);
 
                inputTag.Attributes.Add("required", "required");
            }
 
            // bind angular data model to the control,
            inputTag.MergeAttribute("[(ngModel)]", For.CamelizedName());
 
            // TODO: if adding say text area, you want closing tag. 
            // For input tag you do not have closing or self-closing
            inputTag.TagRenderMode = TagRenderMode.StartTag;
 
            // now generate the outer wrapper for the form group, 
            // get ready to start filling it with content prepared above
            output.TagName = "div";
            output.Attributes.Add("class", "form-group");
 
            // first the label
            output.Content.AppendHtml(labelTag);
 
            // Some input controls use bootstrap 
            // "input group"- wrap the input tag with an input group, if needed
            switch (dataType)
            {
                case "Currency":
                    var divInputGroup = new TagBuilder("div");
                    divInputGroup.MergeAttribute("class", "input-group");
 
                    var spanInputGroupAddon = new TagBuilder("span");
                    spanInputGroupAddon.MergeAttribute("class", "input-group-addon");
                    spanInputGroupAddon.InnerHtml.Append("$");
 
                    divInputGroup.InnerHtml.AppendHtml(spanInputGroupAddon);
                    divInputGroup.InnerHtml.AppendHtml(inputTag);
 
                    output.Content.AppendHtml(divInputGroup);
                    break;
 
                default:
                    // most of the time we simply append the input controls prepared above
                    output.Content.AppendHtml(inputTag);
                    break;
            }
 
            // add the validation prepared earlier, to the end, last of all
            output.Content.AppendHtml(validationBlock);
        }
    }
}

And we can now delete all the excess baggage out of the view, AboutComponent.cshtml so that it reads:

HTML
@using A2SPA.ViewModels
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *,A2SPA
@model TestData
 
@{
    ViewData["Title"] = "About";
}
<h2>@ViewData["Title"].</h2>
<h3>@ViewData["Message"]</h3>
 
<p>Examples of Angular 2 data served by ASP.Net Core Web API:</p>
<form #testForm="ngForm">
    <div *ngIf="testData != null">
        <div class="row">
            <div class="col-md-6">
                <div class="panel panel-primary">
                    <div class="panel-heading">Data Entry</div>
                    <div class="panel-body">
                        <tag-di for="Username"></tag-di>
                        <tag-di for="Currency"></tag-di>
                        <tag-di for="EmailAddress"></tag-di>
                        <tag-di for="Password"></tag-di>
                    </div>
                    <div class="panel-footer">
                        <button type="button" class="btn btn-warning" 
                         (click)="addTestData($event)">Save to database</button>
                    </div>
                </div>
            </div>
            <div class="col-md-6">
                <div class="panel panel-primary">
                    <div class="panel-heading">Data Display</div>
                    <div class="panel-body">
                        <tag-dd for="Id"></tag-dd>
                        <tag-dd for="Username"></tag-dd>
                        <tag-dd for="Currency" pipe="| 
                        currency:'USD':true:'1.2-2'"></tag-dd>
                        <tag-dd for="EmailAddress"></tag-dd>
                        <tag-dd for="Password"></tag-dd>
                    </div>
                    <div class="panel-footer">
                        <button type="button" class="btn btn-info" 
                        (click)="getTestData($event)">Get last record from database</button>
                    </div>
                </div>
            </div>
        </div>
    </div>
</form>
 
<div *ngIf="errorMessage != null">
    <p>Error:</p>
    <pre>{{ errorMessage  }}</pre>
</div>

Where to From Here?

In Part 4, we'll use our new username and password tag helpers and add token validation and then in Part 5, we'll move on to automate generation of Angular / Typescript data models and data services from our server side data models and Web API using Swagger, or more particularly NSwag.

Points of Interest

Hopefully, you're seeing how you can use ASP.NET Core together with Angular 2 and the potential to accelerate development. But there are so many other things you can do:

Data Types - There are other data types to be added, add support for date, date time, time, etc.

Add extra attributes to our tag helper to override defaults - For example, you might provide options for a different property name, or parent object name, to force required when the data model does not require it, or vice-versa.

Freedom to innovate and change - Who has added a date time picker in 10 places, but had someone want to change all of them, or some of them? Much of your work can be done in one place, the tag helper, and the results emitted across your whole site automatically.

Style and class changes - We have not yet added class over-rides, but custom classes, styles and even complete layouts could be drive in attributes, or with other tag helpers. You may like to have a tag helper to generate a set of tiled products, or a shopping cart view, or have an attribute in your tag helper that emits a list compatible view.

Form variations - In this example, we've had a simple form group with label above the input field. There is nothing (apart from a little time and effort) from using this same technique to emit horizontal forms, with the prompt to the left of the input tag, or have the option to switch with a simple attribute change.

Expanding validation and client side code - It is also possible to emit blocks of client side JavaScript code, ready to work alongside your Angular, and drop these into the page alongside the code you have here. This takes some work, but can push settings or variables to the client.

Finally - do a Henry Ford on your pages, automate everything possible. Our data model can become the centre of application, where it’s already (possibly) centre by use of EF core / Entity Framework core's code first to generate and maintain your database, but why not more. Keep using your tag helpers wherever you can to do as much as you can.

The source for Part 3 is also Github here or if the download link is broken, you can find it here.

History

License

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

Share

About the Author

Robert_Dyball
Software Developer (Senior)
Australia Australia
Robert works as a full stack developer in the financial services industry. He's passionate about sustainable software development, building solid software and helping to grow teams. He's had 20+ years of development experience across many areas that include scheduling, logistics, telecommunications, manufacturing, health, insurance and government regulatory and licensing sectors. Outside work he also enjoys electronics, IOT and helping a number of non-profit groups.

Comments and Discussions

 
Questionmin length not working Pin
Member 126509205-Jun-17 21:06
MemberMember 126509205-Jun-17 21:06 
AnswerRe: min length not working Pin
Robert_Dyball6-Jun-17 12:00
professionalRobert_Dyball6-Jun-17 12:00 
QuestionThe walking dead Pin
mag1310-Mar-17 0:24
Membermag1310-Mar-17 0:24 
AnswerRe: The walking dead Pin
Robert_Dyball10-Mar-17 11:51
professionalRobert_Dyball10-Mar-17 11:51 
Generalfuture mode ... Pin
mag1310-Mar-17 12:38
Membermag1310-Mar-17 12:38 
GeneralRe: future mode ... Pin
Robert_Dyball10-Mar-17 13:20
professionalRobert_Dyball10-Mar-17 13:20 
QuestionAs soon as you say Angular 2.4... Pin
Dewey2-Mar-17 9:46
MemberDewey2-Mar-17 9:46 
AnswerRe: As soon as you say Angular 2.4... Pin
Robert_Dyball2-Mar-17 10:40
professionalRobert_Dyball2-Mar-17 10:40 
AnswerRe: As soon as you say Angular 2.4... Pin
Robert_Dyball2-Mar-17 11:01
professionalRobert_Dyball2-Mar-17 11:01 
QuestionSome missing descriptions in article Pin
LambertWu20-Feb-17 4:37
MemberLambertWu20-Feb-17 4:37 
AnswerRe: Some missing descriptions in article Pin
Robert_Dyball20-Feb-17 8:41
professionalRobert_Dyball20-Feb-17 8:41 
thank you Andyr,

I've just submitted the update; hopefully get though approval/editors soon,

thanks again for your feedback,

regards
Robert.

modified 20-Feb-17 21:41pm.

GeneralSome notes to the tag helper Pin
Klaus Luedenscheidt20-Feb-17 0:43
MemberKlaus Luedenscheidt20-Feb-17 0:43 
GeneralRe: Some notes to the tag helper Pin
Robert_Dyball20-Feb-17 8:40
professionalRobert_Dyball20-Feb-17 8:40 
GeneralRe: Some notes to the tag helper Pin
Robert_Dyball21-Feb-17 1:11
professionalRobert_Dyball21-Feb-17 1:11 
QuestionThanks for this Pin
Hein_A19-Feb-17 14:18
MemberHein_A19-Feb-17 14:18 
AnswerRe: Thanks for this Pin
Robert_Dyball19-Feb-17 14:45
professionalRobert_Dyball19-Feb-17 14:45 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.