Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 4.

Вот я добрался и до новой части в цикле статей о SPA на основе Angular 4 и ASP.NET MVC Core. Как и обещал, в этой части будем реализовывать следующие вещи:

  • добавим поддержку небольшой базы данных PostgreSQL
  • добавим новый HTML Tag Helper для отработки ввода данных

Поддержку базы данных добавим для того, что бы хранить введенные данные. Она будет сделана на самом простом уровне, не сильно вдаваясь в детали. Почему PostgreSQL? Я работаю с этой СУБД довольно давно и мне она нравиться. Это мощная СУБД, которая справляется с большими объемами данных не хуже чем MS SQL или любая другая СУБД, но при этом абсолютно бесплатная :).

Добавление поддержки PostgreSQL в проект

Вначале добавим необходимые модули в наш проект. Во первых нам понадобится Npgsql EF Core providerNpgsql.EntityFrameworkCore.PostgreSQL. Для того что бы его установить, нужно выполнить следующие действия:

  1. открываем NuGet Manager
  2. выполняем команду Install-Package Npgsql.EntityFrameworkCore.PostgreSQL
  3. выполняем команду Install-Package Microsoft.EntityFrameworkCore.Tools
Visual Studio 2017 - Открытие NuGet Консоли

Visual Studio 2017 — Открытие NuGet Консоли

 

Visual Studio 2017 - Выполнение комманды NuGet

Visual Studio 2017 — Выполнение комманды NuGet

Теперь стоит добавить в проект строку подсоединения к БД. Для простоты примера добавим ее в файл appsettings.json в корневой папке проекта. Этот файл будет выглядеть следующим образом:

{
  "ConnectionStrings": {
    "SpaDbConnection": " Host=localhost;Port=5432;Username=spa_user;Password=spa_u1234;Database=spa;Pooling=true; "
  },

  "Logging": {
    "IncludeScopes": false,
    "LogLevel": {
      "Default": "Warning"
    }
  }
}

Как выдите, мы хотим подсоединиться к БД ‘spa’, используя имя пользователя ‘spa_user’ и пароль ‘spa_u1234’к серверу на локальной машине. Что бы это работало, нам нужно создать данного пользователя в PostgreSQL сервере. Для этого сделайте следующие:

  1. Запустите pgAdmin III
  2. Разверните дерево сервера с левой стороны и найдите раздел Login Roles.
  3. Щелкните правой кнопкой и выберете New Login Role …
  4. В новом диалоге введите имя пользователя на первой вкладке Properties, пароль на второй вкладке Definition, отметьте Can create database на третей вкладке Role privileges.
  5. Нажмите ОК что бы сохранить.
Создание логина в PostgreSql

Создание логина в PostgreSql

 

Создание логина в PostgreSql

Создание логина в PostgreSql

 

Создание логина в PostgreSql - Установка опций

Создание логина в PostgreSql — Установка опций

 

Создание логина в PostgreSql - Установка пароля

Создание логина в PostgreSql — Установка пароля

 

Следующий шаг – создадим классы для таблиц базы данных. Обычно это называется Модель и классы ложатся на таблицы БД один к одному. Такие классы хранят данные извлеченные из БД и используются для обработки данных в приложении. Структура этих данных может совпадать а может и отличаться от моделей представления, тех классов, которые мы используем для отправки данных в приложение Angualar.

Часто новый проект должен работать с уже существующей БД, и тогда классы моделей нужно создавать на основе существующих таблиц БД. И такие классы быстрее всего не будут совпадать с моделями представлений и данные нужно преобразовывать в формат модели представления. В нашем случае, для упрощения примера, мы сделаем обе модели одинаковые. Т.е. наш класс TestData будет полностью соответствовать структуре таблицы в БД.

Что бы извлекать данные из БД нам понадобится класс для работы с data context. Создадим папку Data и добавим в нее новый класс SpaDbContext который будет наследоваться от DbContext (этот класс находится в пространстве имен Microsoft.EntityFrameworkCore). Новый файл будет выглядеть следующим образом:

using AngularSpa.ViewModels;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace AngularSpa.Data
{
    public class SpaDbContext : DbContext
    {
        public SpaDbContext(DbContextOptions<SpaDbContext> options) : base(options)
        {
        }

        public DbSet<TestData> TestData { set; get; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<TestData>().ToTable("TestData");
        }
    }
}

Этот класс будет отвечать за доступ к данным в нашей БД. Кроме того, мы переопределили метод OnModelCreating, который настраивает маппинг между нашими классами модели (TestData) и таблицами в БД.

Теперь обновим метод ConfigureServices в файле Startup.cs:

	public void ConfigureServices(IServiceCollection services)
	{
		string connString = Configuration.GetConnectionString("SpaDbConnection ");
		services
			.AddEntityFrameworkNpgsql()
			.AddDbContext<SpaDbContext>(options => options.UseNpgsql(connString));

		// Add framework services.
		services.AddMvc();
	}

Не забудьте добавить следующие пространства имен в файл Startup.cs

using AngularSpa.Data;
using Microsoft.EntityFrameworkCore;

Что бы можно было сохранять данные в БД и легко извлекать их оттуда по идентификатору, нам нужно добавить поле Id в наш класс TestData:

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

Добавим новый класс DbInitializer в папку Data. Этот класс будет инициализировать нашу БД:

using AngularSpa.ViewModels;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace AngularSpa.Data
{
    public class DbInitializer
    {
        public static void Initialize(SpaDbContext context)
        {
            context.Database.EnsureCreated();

            if(context.TestData.Any())
            {
                // we have data in database
                return; 
            }

            // add one record to database
            TestData rec = new TestData
            {
                Username = "Admin",
                EmailAddress = "admin@siv-blog.com",
                Password = "pass1234",
                Currency = 1000000
            };

            context.TestData.Add(rec);
            context.SaveChanges();
        }
    }
}

В этом классе у нас единственный статический метод, который делает следующие:

  • создает БД если она еще не создана, используя модель данных из нашего проекта
  • если БД уже существует – ничего не делаем и переходим к проверки наличия данных в БД
  • если в БД уже есть записи – сразу выходим
  • если наша таблица пустая – добавим в нее одну тестовую запись
  • Теперь перепишем код для API контролера SampleDataController:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using AngularSpa.ViewModels;
using AngularSpa.Data;

// For more information on enabling Web API for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860
namespace AngularSpa.Api
{
    [Route("api/[controller]")]
    public class SampleDataController : Controller
    {
        SpaDbContext _Context = null;

        public SampleDataController(SpaDbContext context)
        {
            _Context = context;
        }

        // GET: api/values
        [HttpGet]
        public TestData Get()
        {
            return _Context.TestData.LastOrDefault();
        }

        // GET api/values/5
        [HttpGet("{id}")]
        public string Get(int id)
        {
            return "value";
        }

        // POST api/values
        [HttpPost]
        public TestData Post([FromBody]TestData value)
        {
            value.Id = 0;
            var newData = _Context.Add(value);
            _Context.SaveChanges();

            return newData.Entity as TestData;
        }

        // PUT api/values/5
        [HttpPut("{id}")]
        public void Put(int id, [FromBody]string value)
        {
        }

        // DELETE api/values/5
        [HttpDelete("{id}")]
        public void Delete(int id)
        {
        }
    }
}

Теперь наш API контролер делает следующие:
Для метода GET возвращает последнюю запись из БД.
Для метода POST вставляет новую запись в конец таблицы.

Мы не делаем проверку данных на сервере, так же не придумываем сложную логику для метода GET. Это все добавим немного позже. Пока нам важно получить быстрый результат – извлекать данные из БД и вставлять в БД новые записи.

Мы почти закончили наши изменения. Осталось сделать так, что бы приложение вызывало DbInitializer.Initialize единожды при запуске приложения. Для этого будем передавать внутрь функции Configure наш контекст БД:

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory, SpaDbContext context)

А в самой функции в конце добавим код:

if(env.IsDevelopment())
{
	DbInitializer.Initialize(context);
}

Теперь проект готов к тестированию. Нажимаем F5 и запускаем проект на исполнение. Открываем страничку About и видим данные из БД!

Данные из БД на странице Angular

Данные из БД на странице Angular

А самое главное, наш код автоматически создал БД в PostgreSQL сервере. В этом легко убедиться если просмотреть информацию о сервере в pgAdmin III.

Создание БД в PostgreSql

БД в PostgreSql

Добавление кода для сохранения данных в клиентский код Angular

Теперь пришло время обновить наш клиентский код, что бы использовать POST запрос для сохранения данных в БД.

Для начала нужно обновить модель TestData в файле /wwwroot/app/models/TestData.ts и добавить в нее новое поле Id, которое мы добавили в класс TestData.

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

export class TestData 
{
    id              : number;
    username        : string;
    currency        : number;
    emailAddress    : string;
    password        : string;
}

Теперь обновим наш клиентский Angular сервис, который извлекает данные с сервера при помощи API запросов. Новая версия будет выглядеть следующим образом:

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);
    }

    private handleError(error: Response | any)
    {
        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);
    }
}

Тут мы добавили новую функцию addSampleData() которая отсылает данные на сервер. Теперь нужно добавить код, который будет использовать эту новую функцию нашего сервиса. Открываем и правим файл /wwwroot/app/about.component.ts:

import { Component, OnInit } from '@angular/core';
import { SampleDataService } from './services/SampleData.services';
import { TestData } from './models/TestData';

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

export class AboutComponent implements OnInit
{
    testData: TestData = null;
    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);
    }
}

Как вы видите, были добавлены две функции: getTestData() и addTestData(event: Event):void.

Первая отвечает за получение данных с сервера. На самом деле мы просто переместили код из функции инициализации ngOnInit() в отдельную функци. А в ngOnInit() вызывем нашу новую функцию getTestData(). Так правильнее с точки зрения архитектуры приложения. Вторая функция отвечает за отправку данных на сервер при помощи нашего сервиса SampleDataService. Кроме отсылки данных, мы так же сразу принимаем записанные данные в ответе и показываем их на странице. Т.е делаем запись и обновление в одном запросе.

Теперь обновим файл /Views/Partial/AboutComponent.cshtml. Мы добавим 2 новые кнопки на нашу форму. Одну для извлечения данных с сервера, вторая для сохранения новой записи на сервере. Так как файл довольно большой, а изменения не очень большие, то я покажу только измененные кусочки.

Кусок кода для кнопки добавления записи:

                        
	<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 class="panel-footer">
		<button type="button" class="btn btn-warning" (click)="addTestData($event)">Save to database</button>
	</div>

Кусок кода для кнопки чтения записи:

                        
	<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 class="panel-footer">
			<button type="button" class="btn btn-info" (click)="getTestData()">Get Last Record</button>
		</div>
	</div>

Обратите внимание, что во второй части кода, кроме добавления кнопки, мы добавили еще и поле Id:

  
<tag-dd for="Id"></tag-dd>

С HTML Helpers это достаточно просто, не так ли.

Теперь можно скомпилировать и запустить проект. Вы должны увидеть новое поле Id и две новые кнопки как на скриншоте ниже.

Добавлены новые кнопки для сохранения и извлечения данных с сервера

Добавлены новые кнопки для сохранения и извлечения данных с сервера

Если вы откроете Developer Tools в браузере и нажмете на кнопку Get Last Record, то увидите что страница отсылает запрос к серверу и получает данные в виде JSON.
А если вы ведете новые данные и нажмете кнопку Save, то будет видно что страница отсылает POST запрос с новыми данными, которые приходят назад в ответе. В то же время в БД появляется новая запись с новым Id.

Мы убедились, что наша логика для сохранения введенных данных в БД и извлечения их оттуда работает. Приступаем к следующему шагу.

Добавления Tag Helper для вводимых данных

Если посмотреть на файл /Views/Partial/AboutComponent.cshtml, то видно, что секция для отображения данных намного меньше, чем секция для ввода данных, хотя у нас всего 4 поля. Было бы хорошо сократить повторяющийся код как можно сильнее.

Правда создавать единый Tag Helper будет сложнее, чем для отображения данных. Каждое поле имеет много специфичного кода, но мы постараемся. Начнем работать с самым простым полем – Password. Итак, мы имеем следующую 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>

Начнем с создания класса Tag Helper. Создадим в папке TagHelpers новый файл TagDITagHelper.cs.

Унаследуюм наш новый класс от TagHelper. Добавим необходимое пространство имен, зададим целевой HTML элемент, который будет использоваться для этого TagHelper, и добавим поле, которое будет хранить View Model обьект. Наш код на данный момент должен выглядеть следующим образом:

using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace AngularSpa.TagHelpers
{
    [HtmlTargetElement("tag-di")]
    public class TagDITagHelper : TagHelper
    {
        [HtmlAttributeName("for")]
        public ModelExpression For { get; set; }

    }
}

Следующий шаг – добавление метода Process() который и будет строить наш HTML. Полный код файла будет выглядеть так:

using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace AngularSpa.TagHelpers
{
    [HtmlTargetElement("tag-di")]
    public class TagDITagHelper : TagHelper
    {
        [HtmlAttributeName("for")]
        public ModelExpression For { get; set; }

        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");
            inputTag.TagRenderMode = TagRenderMode.StartTag;

            output.TagName = "div";
            output.Attributes.Add("class", "form-group");

            output.Content.AppendHtml(labelTag);
            output.Content.AppendHtml(inputTag);
        }
    }
}

Самая важная часть здесь – это строка

inputTag.TagRenderMode = TagRenderMode.StartTag;

Попробуйте ее закомментировать и запустить проект. Он не запуститься. Это потому что <input type=»text» /> без этой строки рендериться как:

<input></input>

А это неправильно и при запуске проекта вы увидите ошибку в JavScript консоли.

Но прежде чем запускать проект, нам нужно заменить старый HTML код нашего Passwod поля на наш новый Tag Helper в файле /Views/Partial/AboutComponent.cshtml. Вот этот старый код:

                        

	<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>

Замените на этот новый код

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

Вот теперь можно запускать проект.

HTML разметка для поля Password

HTML разметка для поля Password

Если вы посмотрите на сгенерированный HTML код то увидите:

  

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

Это конечно близко к нашему изначальному HTML, но все же не хватает многих атрибутов. Давайте добавим их. В начале добавим атрибуты валидации, которые будут проверять минимальную и максимальную длину вводимого текста. Для этого добавим новый класс – помощник FieldLengthValidation в папку TagHelpers. В этот файл мы добавим код, который будет извлекать Min и Max значения длина поля из метаданных модели. Код будет выглядеть следующим образом:

using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;

namespace AngularSpa.TagHelpers
{
    public static class FieldLengthValidation
    {
        public enum Limit
        {
            Min,
            Max
        };

        public static int? GetMinLength(this ModelMetadata model)
        {
            return GetMinOrMaxLengthValue(model, Limit.Min);
        }

        public static int? GetMaxLength(this ModelMetadata model)
        {
            return GetMinOrMaxLengthValue(model, Limit.Max);
        }

        public static int? GetMinOrMaxLengthValue(this ModelMetadata model, Limit limit)
        {
            IList<object> validationItems = ((DefaultModelMetadata)model).ValidationMetadata.ValidatorMetadata;
            if (!validationItems.Any())
                return null;

            bool hasStringValidationItems =
                validationItems.Any() &&
                validationItems.Any(a => (a as ValidationAttribute).GetType().ToString().Contains("StringLengthAttribute"));

            var attr = validationItems
                .DefaultIfEmpty(null)
                .FirstOrDefault(a => (a as ValidationAttribute).GetType().ToString().Contains("StringLengthAttribute"));

            if (attr == null)
                return null;

            StringLengthAttribute slAttr = (attr as StringLengthAttribute);

            if (limit == Limit.Min)
                return slAttr.MinimumLength;
            else
                return slAttr.MaximumLength;
        }
    }
}

Теперь нам нужно добавить небольшую функцию для форматирования имени поля модели на основе кода класса модели. Этот новый метод будет использоваться для правильного задания ID и NAME нашего HTML контрола. Итак, добавим функцию AngularPropertyName в класс ModelExpressionExtention:

public static string AngularPropertyName(this ModelExpression modelExpression)
{
	string propertyName = modelExpression.Name;
	propertyName = char.ToLower(propertyName[0]).ToString() + propertyName.Substring(1);

	return propertyName;
}

Теперь можно улучшить код класса нашего HTML помощника для отображения полей ввода:

using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace AngularSpa.TagHelpers
{
    [HtmlTargetElement("tag-di")]
    public class TagDITagHelper : TagHelper
    {
        [HtmlAttributeName("for")]
        public ModelExpression For { get; set; }

        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            var labelTag = new TagBuilder("label");
            labelTag.InnerHtml.Append(For.Metadata.Description);
            labelTag.MergeAttribute("for", For.AngularPropertyName());
            labelTag.AddCssClass("control-label");

            var inputTag = new TagBuilder("input");
            inputTag.AddCssClass("form-control");
            inputTag.TagRenderMode = TagRenderMode.StartTag;
            inputTag.MergeAttribute("type", "password");
            inputTag.MergeAttribute("id", For.AngularPropertyName());
            inputTag.MergeAttribute("name", For.AngularPropertyName());
            inputTag.MergeAttribute("placeholder", For.Metadata.Description);
            inputTag.MergeAttribute("[(ngModel)]", For.AngularName());

            DefaultModelMetadata model = (DefaultModelMetadata)For.Metadata;

            int? value = model.GetMinLength();
            if(value != null)
            {
                inputTag.Attributes.Add("minLength", value.ToString());
            }

            value = model.GetMaxLength();
            if (value != null)
            {
                inputTag.Attributes.Add("maxLength", value.ToString());
            }

            if(model.IsRequired)
            {
                inputTag.Attributes.Add("required", "required");
            }

            output.TagName = "div";
            output.Attributes.Add("class", "form-group");

            output.Content.AppendHtml(labelTag);
            output.Content.AppendHtml(inputTag);
        }
    }
}

Все готово, можно собирать проект и запускать его. Теперь вы должны видеть, что поле Password стало полностью функциональным, но при этом оно полностью генерится из вот такой простой строки:

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

Это реально классно. Мы только что автоматизировали еще небольшой кусочек нашего проекта.

Расширяем HTML Helper для поля Currency

Теперь перейдем к следующему элементу управления – Currency. На данный момент у нас следующая 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>


Ну что же, заменим ее на наш HTML Helper

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

Теперь посмотрим на разметку. Она похожа на разметку Password но есть и отличия:

  • у нас другой тип тэга INPUT
  • есть дополнительный DIV для значка валюты
  • есть дополнительный DIV для вывода сообщения об ошибке, если пользователь не ввел суму – оставил поле пустым

Для начала давайте добавим поддержку для различных типов данных. В зависимости от типа данных мы будем устанавливать различные типы данных элемента INPUT. Проще всего сделать это через switch … case:

	DefaultModelMetadata model = (DefaultModelMetadata)For.Metadata;

	string typeName = "text";
	switch(model.DataTypeName)
	{
		default:
			break;

		case "Password":
			typeName = "password";
			break;

		case "Currency":
			typeName = "number";
			break;
	}

	inputTag.MergeAttribute("type", typeName);


Следующий шаг – добавить DIV со значком валюты. Что бы сделать это, нам нужно изменить код для добавления элементов. Замените эту строчку:

output.Content.AppendHtml(inputTag);

на такой код:

            switch (model.DataTypeName)
            {
                default:
                    output.Content.AppendHtml(inputTag);
                    break;


                case "Currency":
                    {
                        var divInputGroup = new TagBuilder("div");
                        divInputGroup.MergeAttribute("class", "input-group");

                        var divCurrencySign = new TagBuilder("div");
                        divCurrencySign.MergeAttribute("class", "input-group-addon");
                        divCurrencySign.InnerHtml.Append("$");

                        divInputGroup.InnerHtml.AppendHtml(divCurrencySign);
                        divInputGroup.InnerHtml.AppendHtml(inputTag);

                        output.Content.AppendHtml(divInputGroup);
                        break;
                    }
            }

Этот код будет добавлять необходимый HTML для любого поля типа Currency. Пока только добавляется знак валюты. Можно запустить проект и посмотреть результат. Практически ничем не отличается от оригинала.

Следующий шаг – добавим сообщения об ошибках валидации данных. Снова, это лучше всего генерировать на лету, используя данные внутри метаданых класса модели. Так мы избежим ручной работы. Оригинальная разметка для этих сообщений такая:

  

<div *ngIf="currency.errors && (currency.dirty || currency.touched)"

     class="alert alert-danger">
    <div [hidden]="!currency.errors.required">
        Payment Amount is required
    </div>
</div>

Чтобы сгенерировать этот HTML код нам нужно поправить код нашего HTML Helper и добавить в него эту разметку. Для простоты кода мы будем всегда добавлять блок сообщения об ошибке если у нас будет установлен атрибут Required. Код будет выглядеть следующим образом:

public override void Process(TagHelperContext context, TagHelperOutput output)
{
	DefaultModelMetadata model = (DefaultModelMetadata)For.Metadata;

	var labelTag = new TagBuilder("label");
	labelTag.InnerHtml.Append(For.Metadata.Description);
	labelTag.MergeAttribute("for", For.AngularPropertyName());
	labelTag.AddCssClass("control-label");

	var inputTag = new TagBuilder("input");
	inputTag.AddCssClass("form-control");
	inputTag.TagRenderMode = TagRenderMode.StartTag;
	inputTag.MergeAttribute("id", For.AngularPropertyName());
	inputTag.MergeAttribute("name", For.AngularPropertyName());
	inputTag.MergeAttribute("placeholder", For.Metadata.Description);
	inputTag.MergeAttribute("[(ngModel)]", For.AngularName());

	string typeName = "text";
	switch(model.DataTypeName)
	{
		default:
			break;

		case "Password":
			typeName = "password";
			break;

		case "Currency":
			typeName = "number";
			break;
	}

	inputTag.MergeAttribute("type", typeName);

	int? value = model.GetMinLength();
	if(value != null)
	{
		inputTag.Attributes.Add("minLength", value.ToString());
	}

	value = model.GetMaxLength();
	if (value != null)
	{
		inputTag.Attributes.Add("maxLength", value.ToString());
	}

	// add validation box
	inputTag.MergeAttribute("#" + For.AngularPropertyName(), "ngModel");

	TagBuilder validationBox = null;

	if (model.IsRequired)
	{
		inputTag.Attributes.Add("required", "required");

		validationBox = new TagBuilder("div");
		validationBox.MergeAttribute("class", "alert alert-danger");
		validationBox.MergeAttribute("*ngIf", For.AngularPropertyName() + ".errors && (currency.dirty || currency.touched)");

		TagBuilder validationMsgBox = new TagBuilder("div");
		validationMsgBox.MergeAttribute("[hidden]", "!currency.errors.required");
		validationMsgBox.InnerHtml.Append(For.Metadata.Description + " is required!");

		validationBox.InnerHtml.AppendHtml(validationMsgBox);
	}


	output.TagName = "div";
	output.Attributes.Add("class", "form-group");

	output.Content.AppendHtml(labelTag);

	switch (model.DataTypeName)
	{
		default:
			output.Content.AppendHtml(inputTag);
			break;


		case "Currency":
			{
				var divInputGroup = new TagBuilder("div");
				divInputGroup.MergeAttribute("class", "input-group");

				var divCurrencySign = new TagBuilder("div");
				divCurrencySign.MergeAttribute("class", "input-group-addon");
				divCurrencySign.InnerHtml.Append("$");

				divInputGroup.InnerHtml.AppendHtml(divCurrencySign);
				divInputGroup.InnerHtml.AppendHtml(inputTag);

				output.Content.AppendHtml(divInputGroup);
				break;
			}
	}

	if (validationBox != null)
		output.Content.AppendHtml(validationBox);
}

Теперь можно собрать проект и запустить. Я специально оставил в коде старую разметку и новую. Если сейчас удалить текст из поля Currency то мы увидим следующую картину:

Ошибка для пустого поля Currency

Ошибка для пустого поля Currency

Как видите, мы используем везде поле Description из метаданых, и это формирует не совсем правильный текст в сообщении об ошибке и на месте placeholder текста внутри элемента INPUT. Что бы исправить это, добавим в класс модели новые параметры атрибута Display:

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

Теперь добавим код в HTML Helper чтобы выбирать нужный текст:

public override void Process(TagHelperContext context, TagHelperOutput output)
{
	DefaultModelMetadata model = (DefaultModelMetadata)For.Metadata;

	string shortName   = model.DisplayName ?? For.Name;
	string labelText   = model.Placeholder ?? shortName;
	string description = model.Description ?? labelText;

	var labelTag = new TagBuilder("label");
	labelTag.InnerHtml.Append(description);
	labelTag.MergeAttribute("for", For.AngularPropertyName());
	labelTag.AddCssClass("control-label");

	var inputTag = new TagBuilder("input");
	inputTag.AddCssClass("form-control");
	inputTag.TagRenderMode = TagRenderMode.StartTag;
	inputTag.MergeAttribute("id", For.AngularPropertyName());
	inputTag.MergeAttribute("name", For.AngularPropertyName());
	inputTag.MergeAttribute("placeholder", shortName);
	inputTag.MergeAttribute("[(ngModel)]", For.AngularName());

	string typeName = "text";
	switch(model.DataTypeName)
	{
		default:
			break;

		case "Password":
			typeName = "password";
			break;

		case "Currency":
			typeName = "number";
			break;
	}

	inputTag.MergeAttribute("type", typeName);

	int? value = model.GetMinLength();
	if(value != null)
	{
		inputTag.Attributes.Add("minLength", value.ToString());
	}

	value = model.GetMaxLength();
	if (value != null)
	{
		inputTag.Attributes.Add("maxLength", value.ToString());
	}

	// add validation box
	inputTag.MergeAttribute("#" + For.AngularPropertyName(), "ngModel");

	TagBuilder validationBox = null;

	if (model.IsRequired)
	{
		inputTag.Attributes.Add("required", "required");

		validationBox = new TagBuilder("div");
		validationBox.MergeAttribute("class", "alert alert-danger");
		validationBox.MergeAttribute("*ngIf", For.AngularPropertyName() + ".errors && (currency.dirty || currency.touched)");

		TagBuilder validationMsgBox = new TagBuilder("div");
		validationMsgBox.MergeAttribute("[hidden]", "!currency.errors.required");
		validationMsgBox.InnerHtml.Append(labelText + " is required!");

		validationBox.InnerHtml.AppendHtml(validationMsgBox);
	}


	output.TagName = "div";
	output.Attributes.Add("class", "form-group");

	output.Content.AppendHtml(labelTag);

	switch (model.DataTypeName)
	{
		default:
			output.Content.AppendHtml(inputTag);
			break;


		case "Currency":
			{
				var divInputGroup = new TagBuilder("div");
				divInputGroup.MergeAttribute("class", "input-group");

				var divCurrencySign = new TagBuilder("div");
				divCurrencySign.MergeAttribute("class", "input-group-addon");
				divCurrencySign.InnerHtml.Append("$");

				divInputGroup.InnerHtml.AppendHtml(divCurrencySign);
				divInputGroup.InnerHtml.AppendHtml(inputTag);

				output.Content.AppendHtml(divInputGroup);
				break;
			}
	}

	if (validationBox != null)
		output.Content.AppendHtml(validationBox);
}

Снова собираем проект, проверяем. На этот раз все должно совпадать. Теперь можно удалить старую разметку и оставить только новый тэг:

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

Расширяем HTML Helper для поля Username

Следующее поле, который мы будем добавлять в HTML Helper – поле Username. Для начала глянем на текущий 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>

И начнем с добавления в код нашего HTML Helper тэга прямо после оригинального кода:

  
<tag-di for="Username"></tag-di>

Уже сейчас можно собрать проект и запустить на исполнение, что бы посмотреть насколько близко мы к нашей цели. Наш HTML Helper уже дает результат близко схожий с нашей оригинальной разметкой. Не хватает только проверки правильности введенных данных. Если вы удалите текст из нового поля, вы не увидите сообщения об ошибке. Вот это мы сейчас и добавим.
Для начала добавим отсутствующие параметры атрибута Display для поля Username в классе модели:

Так же у нас отсутствует атрибут Required и также атрибут для минимальной и максимальной длины поля. Добавим и их. Наш код теперь должен выглядеть так:

        [Required]
        [StringLength(30, MinimumLength = 6)]
        [Display(Description = "Username", Name ="Username", Prompt="Username")]
        public string Username { get; set; }

Отлично, запускаем проект и видим что у нас появляется сообщение об ошибке, если мы удаляем все символы в поле Username.

Ошибка для пустого поля Username

Ошибка для пустого поля Username

Теперь добавим код, который будет выводить сообщения о минимальной и максимальной допустимой длине имени пользователя. Тут достаточно много изменений, так что просто привожу новый код метода:

namespace AngularSpa.TagHelpers
{
    [HtmlTargetElement("tag-di")]
    public class TagDITagHelper : TagHelper
    {
        [HtmlAttributeName("for")]
        public ModelExpression For { get; set; }

        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            DefaultModelMetadata model = (DefaultModelMetadata)For.Metadata;

            string shortName   = model.DisplayName ?? For.Name;
            string labelText   = model.Placeholder ?? shortName;
            string description = model.Description ?? labelText;

            var labelTag = new TagBuilder("label");
            labelTag.InnerHtml.Append(description);
            labelTag.MergeAttribute("for", For.AngularPropertyName());
            labelTag.AddCssClass("control-label");

            var inputTag = new TagBuilder("input");
            inputTag.AddCssClass("form-control");
            inputTag.TagRenderMode = TagRenderMode.StartTag;
            inputTag.MergeAttribute("id", For.AngularPropertyName());
            inputTag.MergeAttribute("name", For.AngularPropertyName());
            inputTag.MergeAttribute("placeholder", shortName);
            inputTag.MergeAttribute("[(ngModel)]", For.AngularName());

            string typeName = "text";
            switch(model.DataTypeName)
            {
                default:
                    break;

                case "Password":
                    typeName = "password";
                    break;

                case "Currency":
                    typeName = "number";
                    break;
            }

            inputTag.MergeAttribute("type", typeName);

            TagBuilder validationBox = new TagBuilder("div");

            int? value = model.GetMinLength();
            if(value != null)
            {
                var minLengthValidation = new TagBuilder("div");
                minLengthValidation.MergeAttribute("[hidden]", string.Format("!{0}.errors.minlength", For.AngularPropertyName()));
                minLengthValidation.InnerHtml.Append(string.Format("{0} must be at least {1} characters long", labelText, value));
                validationBox.InnerHtml.AppendHtml(minLengthValidation);
                inputTag.Attributes.Add("minlength", value.ToString());
            }

            value = model.GetMaxLength();
            if (value != null)
            {
                var maxLengthValidation = new TagBuilder("div");
                maxLengthValidation.MergeAttribute("[hidden]", string.Format("!{0}.errors.maxlength", For.AngularPropertyName()));
                maxLengthValidation.InnerHtml.Append(string.Format("{0} cannot be more than {1} characters long", labelText, value));
                validationBox.InnerHtml.AppendHtml(maxLengthValidation);
                inputTag.Attributes.Add("maxlength", value.ToString());
            }

            // add validation box
            inputTag.MergeAttribute("#" + For.AngularPropertyName(), "ngModel");

            if (model.IsRequired)
            {
                inputTag.Attributes.Add("required", "required");

                validationBox.MergeAttribute("class", "alert alert-danger");
                validationBox.MergeAttribute("*ngIf", For.AngularPropertyName() + ".errors");

                TagBuilder validationMsgBox = new TagBuilder("div");
                validationMsgBox.MergeAttribute("[hidden]", "!" + For.AngularPropertyName()  + ".errors.required");
                validationMsgBox.InnerHtml.Append(labelText + " is required!");

                validationBox.InnerHtml.AppendHtml(validationMsgBox);
            }


            output.TagName = "div";
            output.Attributes.Add("class", "form-group");

            output.Content.AppendHtml(labelTag);

            switch (model.DataTypeName)
            {
                default:
                    output.Content.AppendHtml(inputTag);
                    break;


                case "Currency":
                    {
                        var divInputGroup = new TagBuilder("div");
                        divInputGroup.MergeAttribute("class", "input-group");

                        var divCurrencySign = new TagBuilder("div");
                        divCurrencySign.MergeAttribute("class", "input-group-addon");
                        divCurrencySign.InnerHtml.Append("$");

                        divInputGroup.InnerHtml.AppendHtml(divCurrencySign);
                        divInputGroup.InnerHtml.AppendHtml(inputTag);

                        output.Content.AppendHtml(divInputGroup);
                        break;
                    }
            }

            if (validationBox != null)
                output.Content.AppendHtml(validationBox);
        }
    }
}

Если теперь запустить проект, то можно поэкспериментировать с пустым значение, и значением короче 6 символов для поля Username. В обоих случаях вы будет видеть сообщение об ошибке.

Ошибка для короткого значения поля Username

Ошибка для короткого значения поля Username

Теперь можно удалить оригинальную HTML разметку для поля Username.И нам осталось улучшить последнее поле на нашей форме – Email Address.

Расширяем HTML Helper для поля Email Address

Оригинальная разметка выглядит следующим образом:

                        

	<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>

В принципе, наш HTML Helper Tag уже может практически все, что нужно для поля Email Address. Осталось добавить разметку для вывода сообщения когда Email Address не соответствует заданному регулярному выражению. Ну что же, начнем со стандартного добавления новго тэга для этого поля:

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

Запустим проект и посмотрим что получилось.

Ошибка для пустого поля Email Address

Ошибка для пустого поля Email Address

Пока похоже, что новое поле работает. Но нет проверки по регулярному выражению. Если ввести неправильно email мы не увидим сообщения об ошибке.

Ошибка для поля Email Address с неправильным адресом почты

Ошибка для поля Email Address с неправильным адресом почты

Это легко исправить. Добавляем новый класс RegularExpressionValidation в папку TagHelpers. Добавляем такой код:

using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;

namespace AngularSpa.TagHelpers
{
    public static class RegularExpressionValidation
    {
        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;
        }
    }
}

Этот код проверяет было ли задано регулярное выражение в виде атрибута на классе модели. Если у класса есть такое ограничение – возвращаем строку с регулярным выражением. Иначе возвращаем пустую строку.

Теперь добавим этот метод в код нашего HTML Helper Tag.

            string regex = model.RegexExpression();
            if(regex != string.Empty)
            {
                var regexValidation = new TagBuilder("div");
                regexValidation.MergeAttribute("[hidden]", string.Format("!{0}.errors.pattern", For.AngularPropertyName()));
                regexValidation.InnerHtml.Append(string.Format("{0} is invalid", labelText));
                validationBox.InnerHtml.AppendHtml(regexValidation);
                inputTag.Attributes.Add("pattern", ((DefaultModelMetadata)For.Metadata).RegexExpression());
            }

Теперь удаляем старую HTML разметку для Email Address и собираем проект. Новая разметка в файла AboutComponent.cshtml выглядит так:

                        
	<tag-di for="Username"></tag-di>
	<tag-di for="Currency"></tag-di>
	<tag-di for="EmailAddress"></tag-di>
	<tag-di for="Password"></tag-di>

Совсем неплохо. Используя единственный HTML Helper Tag мы заменили большую Часть кода и теперь генерируем HTML разметку используя аннотацию класса модели данных.
Теперь можно скомпилировать проект и проверить как работает новая валидация и добавление данных.

Итоги

В этой статье мы разобрали две большие темы:

  • Добавление прослойки для работы с базой данных используя PostgreSql и Entity Framework.
  • Создали HTML Helper Tag для отображения элементов управления для ввода данных.

Статья получилась довольна большая, в основном из за обилия кода. Но я думаю, она будет познавательна в плане автоматизации генерации кода при помощи HTML Helper Tags. В следующей статья я собираюсь улучшить HTML Helper Tag для ввода данных и что более важно – попробую генерировать автоматически Angular/Typescript модели данных и сервисы используя C# классы модели данных при помощи NSwag.

Исходный код

Полностью готовый Visual Studio 2017 проект для четвертой части можно загрузить тут: Visual Studio 2017 ASP.NET MVC Core + Angular SPA Project.

Все части серии

Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 1.
Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 2.
Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 3.
Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 4 [читаете сейчас].
Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 5.
Single Page Application : ASP.NET MVC .NET Core 2 + Angular 5. Часть 6.

Добавить комментарий

Ваш e-mail не будет опубликован.