65.9K
CodeProject 正在变化。 阅读更多。
Home

SPA^2 使用 ASP.NET Core 1.1 + Angular 2.4 - 第 5 部分

starIconstarIconstarIconstarIconstarIcon

5.00/5 (14投票s)

2017 年 3 月 17 日

CPOL

17分钟阅读

viewsIcon

21825

downloadIcon

306

创建一个简单的数据网格,提供列表、添加、编辑和删除功能,并使用简单的“父/子”模板来提供查看、编辑或添加功能

引言

如果您正在寻找现成的框架,或者想要服务器端预渲染和 webpack,请停止阅读,这个不适合您。如果您想要低风险并且不喜欢处于测试阶段的新事物,那么也请停止阅读。

另一方面,如果您喜欢尝试一些不同的东西,学习一些新知识(即使它们处于测试阶段,或者有点小问题),那么请继续阅读。

这一系列文章旨在与您分享可用于您自己代码的构建块。这些基于一种技术,将 ASP.NET Core 和 Angular 2 集成起来,使用 ASP.NET Core MVC 视图来代替纯粹的“哑巴”HTML,并使用标签助手使它们更加“智能”,减少重复的代码复制粘贴。

这一系列文章一直在逐步构建使用 both ASP.NET Core MVC 和 Angular 2 的 SPA 的更多组件,并朝着数据驱动一切(尽可能)的目标迈进。为了避免“只见树木不见森林”,我还将代码示例保持为使用少量数据类型,并且,尽管我坚信 TDD,但我省略了单元测试,以免混淆正在讨论的代码。如果兴趣和支持足够,这些额外的数据类型和单元测试可能会在本系列稍后添加。

背景

如果您从这里开始,也许您会想回顾一下之前的几部分

第 2 部分 展示了如何使用我们的服务器端 C# 数据模型动态生成类型安全的 Angular 2 标记和 HTML 标签,使用 ASP.NET Core 标签助手。 第 3 部分 进一步包括了数据输入和 SQL 后端。 第 4 部分 使用 OpenIDDict 添加了 JWT 令牌安全,也是一个使用 第 3 部分 中的标签助手和视图的实际示例。

在第 5 部分中,我们将添加在表格中工作的标签助手,创建一个简单的数据网格。数据网格提供列表、添加、编辑和删除功能,并使用简单的“父/子”模板来提供查看、编辑或添加功能。

异步“CRUD”Web API 服务

Web API 数据服务一直比较基础,包括一个简单的 getadd 方法,验证仅限于前端。请参阅 第 3 部分,我们在其中添加了使用标签助手生成所需标记。

现在我们将完善 Web API 服务,添加完整的 CRUD(创建/读取/更新/删除)支持,并进行异步(或非同步)数据库调用。我们还将添加服务器端验证,该验证(类似于客户端验证)也将由我们的数据模型的元数据驱动。

在更新数据服务之前,请转到 Helpers 文件夹,添加一个名为 DataAnnotations.cs 的新类并进行编辑,添加一个使用以下代码的新扩展方法

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

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

如果我们有一个方法来检查数据是否有效,例如 IsValid(),返回一个布尔值 true/false,那么我们仍然需要另一个方法,例如 ValidationErrors(),来返回结果。虽然您可以有一个 IsValid() 方法,调用 VaildationErrors() 方法返回一个布尔值,但您会第二次调用它来获取验证原因 - 这两种方法都不能使您的代码特别简单或高效。相反,这个新的 IsModelValid() 方法能够提供两种不同的结果;既能返回模型数据是否有效的布尔结果 true/false,又能通过非传统的“out”参数,一次调用就获取到错误列表(如果有的话)。这与内置的 TryParse() 方法类似。

要使用这个新的 IsModelValid() 方法,我们首先需要创建一个空的 ICollection<ValidationResult> 列表,该列表将作为参数传递给该方法。该方法的返回类型是布尔值,因此如果我们有验证错误,方法的返回值将是 false 的布尔结果,同时,该集合将被返回并填充我们的验证错误。如果验证成功,我们将得到 true 的结果,而我们的空集合将被简单地原样返回。

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

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

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

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

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

            return Json(Ok(testData));
        }

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

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

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

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

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

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

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

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

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

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

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

            return Json(NoContent());
        }

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

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

请注意,返回消息类型已从我们的 TestData 类型更改,之前我们返回刚刚添加的数据的副本以获取新项的 .id。现在我们将通过将我们的结果和任何成功/错误消息包装在 IActionResult 类型的内置属性中来扩展该方法的功能。此外,通过使用 Ok(result) 方法,我们生成 HTTP 错误代码 200(表示成功)以及数据,并且通过使用许多其他方便的返回方法,例如 BadRequest(result),我们可以利用 HTTP 错误代码 400 来表示验证错误,这实际上是一个不好的结果。

选择返回结果方法时要小心,因为某些返回方法可能会生成保留的 HTTP 错误代码或让最终用户(和其他开发人员)感到困惑。

SampleDataController.cs 中的代码可以使用下面的最终版本进行更新。数据库现在使用异步(或“非同步”)调用访问,模型驱动的数据验证,现在改进了成功/错误消息和异常处理。

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

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

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

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

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

            return Json(Ok(testData));
        }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Angular 服务 + 组件 CRUD 支持

为了支持我们后端刚刚完成的更改,我们首先需要修改 Angular 服务。将 SampleData.service.ts 文件更改为以下内容

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

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

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

@Injectable()
export class SampleDataService {

    private url: string = 'api/sampleData';

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

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

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

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

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

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

现在我们有了对创建(添加)、读取、更新(编辑)和删除的支持。虽然我们在这里没有使用它,但我添加了一个通过 ID 获取单个记录的方法来演示如何实现这一点。

由于我们来自服务器的数据现在包装在 IActionResult 中,我们将创建一个新的客户端数据模型来匹配。在 /wwwroot/app/models 文件夹中添加一个名为 ViewModelResponse.ts 的新 typescript 类,应将其编辑为如下内容

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

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

数据将在 value 属性中返回,HTTP 状态码在‘statusCode’属性中返回。

当发生错误时,由于错误消息嵌入在 error.value 属性中,我们将创建一个进一步的 typescript 数据模型,以便我们可以更方便地处理它们。在同一个文件夹中,与新的 ViewModelResponse.ts 文件并排放置,创建另一个 typescript 文件,并将其命名为 ErrorResponse.ts ,然后将其编辑为包含以下内容

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

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

接下来,about 组件 about.component.ts 以支持新的 CRUD 操作

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

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

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

    errorMessage: string;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

上面的 About 组件现在包括对新的编辑/添加功能的支持。为了演示如何实现第三方库,我选择了 ngx-toastr,它是一个简单的弹出“toast”通知服务。现在,当发生错误时,而不是记录到控制台(如果您愿意,仍然可以这样做),会创建一个“toast”消息。

顺便说一句,除了成功或错误之外,还会向用户创建一个简单的消息,告诉他们错误信息,使用 Web API 数据验证中的属性名称 + 错误消息。这是 about.component.ts 的一个摘录,当您扩展应用程序时,应该将其移到一个中央库组件中,然后在您的其他组件之间更方便地共享。

...

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

...

Toasts 是弹出消息,可以配置为显示几秒钟后消失(此处使用默认设置)。

要实现 ngx-toastr,请编辑您的 package.json 文件并添加以下内容

"ngx-toastr": "^4.3.0"

确保 JSON 配置文件中每个块的行之间都有逗号,最后一个除外。

接下来更新 _Layout.cshtml 中的 script 块以加载所需的脚本

    <environment names="Development">

. . .

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

    </environment>

    <environment names="Staging,Production">

. . .  

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

    </environment>

我们需要更新 systemjs.config.js 以指向新组件,将其添加到//other libraries部分

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

那么最终的 systemjs.config.js 文件将变为

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

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

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

最后,更新 app.module.ts 以在文件顶部的import部分包含它

import { ToastrModule } from 'ngx-toastr';

并添加这个

ToastrModule.forRoot(),

@ngModule 代码体中的已列出的 import。最终的 app.module.ts 将是

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

// enableProdMode();

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

下次重建时,新包将被加载(如果 Visual Studio 尚未在后台获取它)。

添加用于父/子数据视图的表

在本文中,我们将用表格替换 AboutView 上左侧的 Bootstrap 面板(用于数据输入)。它不会完全变成数据网格,但如果您愿意,肯定可以扩展。我们 AboutView 右侧现有的 Bootstrap 面板将更改为“子视图”,允许我们查看单个项目,添加新项目以及编辑项目。

由于新视图将经过大量修改,而不是一点一点地替换并可能导致 HTML 错误导致 Angular 区域错误,因此以下是新 AboutComponent.cshtml 视图的完整源代码

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

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

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

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

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

为了防止看到没有数据的表格,并使其明显没有数据,现在使用 *ngIf 语句显示一条简单的消息

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

            (no data)

        </div>

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

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

在第二种情况,即有数据的情况下,显示表格。由于数据以行形式显示,因此每行都附加了一个点击事件,使用 Angular 2 (click) 指令调用我们的组件并选择与该行关联的项目。这是 about.component.ts 的一个摘录,显示了调用的新方法

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

我们的 onclick 事件从行中填充了两个不同数据副本;一个副本称为 selectedItem - 顾名思义,用于跟踪 selectedItem。它使用 = 符号复制,这意味着它与从中复制的数组元素相同。第二个副本使用 ES2015 Object.assign() 命令进行“深拷贝”(如果不支持,将使用 polyfills 处理,或者直到支持为止),该命令将创建一个包含相同数据但与源数据不同的副本

我们将使用第二个副本进行编辑,否则编辑将显示在表格可见的数据中,这意味着我们还需要提供撤销方法,以便在取消编辑时刷新数据网格。

注意:如果您决定更改此设置,您可以深拷贝数据而不进行 selectedItem 浅拷贝,但然后需要提供撤销(否则重新获取数据),或者另一方面,在突出显示行时,您需要比较记录的 id,如下所示

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

而不是像我在这里检测到相等性这样的简单选项

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

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

标签助手重构

为了让“关于页面”支持 HTML 表格作为“父视图”并包含一个包含可编辑表单的面板作为“子视图”,我们需要修改我们的标签助手,以便我们能够控制命名。到目前为止,客户端使用的 Angular 变量都使用了约定进行前缀,以类的名称开头。C# 类 TestData 被“驼峰化”为 testData,然后与每个属性的名称结合使用。

现在我们将编辑数据输入和数据显示标签助手,以添加对自定义前缀的支持,以便更好地控制我们生成的 Angular 数据绑定变量名。

在每个 TagDdTagHelper.cs TagDiTagHelper.cs public class … 语句正下方,请添加以下内容

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

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

这将支持两个新的可选属性。第一个是覆盖默认数据绑定变量名,以防您不想与默认值的另一个实例冲突,因为默认值基于属性名。

var=”variableName”

第二个可选属性是提供父变量名,以防我们需要覆盖默认值(当前是类名)

par=”parentName”

目前,我们正在使用一个扩展方法 VariableNames.cs ,如下所示。它提供了一个非常简单的变量名,由类名和属性名组成。这将要被替换。

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

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

首先,将上述代码中的类名从 VariableNames 重命名为 TagHelperHelpers,然后将文件名从 VariableNames.cs 重命名为 TagHelperHelpers.cs (为了方便起见并避免混淆)。

将 “CamelizedName” 方法替换为 GetDataBindVariableName 方法,使用以下代码

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

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

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

接下来在数据输入标签助手类 TagDiTagHelper.cs 中,我们需要更新它,因为我们删除了“驼峰化”名称扩展方法。要创建正确的数据绑定,请将其从以下内容更改为

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

to

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

您会看到我们仍然将其用作模型属性上的扩展方法(这使我们可以访问类名、属性名和所有元数据),但现在增加了两个新参数(ParVar),我们可以引入可选的父名称和变量名属性。

注意:常规惯例规定这里的变量应该是驼峰式大小写,我保留了名称原样,但您也可以选择引入后备变量或分配给一个名称更常规的局部变量。

接下来,更改数据显示标签助手 TagDdTagHelper.cs 中的相应代码,其中包含以下内容

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

将其更改为

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

我们现在应该能够构建并查看我们的新代码。登录(或注册然后登录),您将看到父/子视图在起作用。

尝试单击一行,您将看到 selectItem,然后单击 [?] 图标,您将能够编辑一个项目。

为了让您能够清除任何更改,您可以添加一个“取消”按钮,编辑 AboutComponent.cshtml 文件,将 About 视图更改为在此新按钮插入到添加编辑按钮正下方。

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

在这里无需更改我们的 About 组件,因为我们使用了内置的 .reset() 方法来清除和重置表单。否则,我们将面临一个编辑的表单验证属性影响另一个。

接下来尝试保存一些(显然)无效数据,例如空的密码或用户名,无论是作为新项还是已编辑的项。应该会显示客户端错误,使用 Angular/Bootstrap 验证,但您仍然应该能够提交这些无效数据。

您应该会看到一个类似的 toast 显示出了问题。这是服务器端验证在起作用。为了让这种服务器端验证作为备份,并避免与无效数据进行往返,我们将禁用按钮,如果存在任何表单验证错误。更新添加编辑按钮以包含 disabled 指令

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

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

与需要更改样式的 [hidden][required] 指令不同,[disabled] 开箱即用。

标签助手 – 进一步的添加和重构

我们要修复的下一个问题是清理表格中数据的渲染。目前,所有数据都显示为未格式化,或使用 Angular 的默认设置。正如我们创建了数据显示标签助手一样,现在我们将创建两个新的标签助手 – 一个用于表格标题单元格,另一个用于表格数据。

表格标题目前重复且硬编码。如果有人想更改列的标题,那么我们可能需要在多个视图中更改它。在 Helpers 文件夹中创建一个新的 C# 类文件,名为 TabCHTagHelper.cs 并更新它以包含以下内容

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

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

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

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

这个标签助手将与其他标签助手不同,而不是定位一个完整的标签,例如

 <tag-di …

相反,我们将定位一个属性。在这种情况下,我们也确保新属性只会影响表格标题标签,即 <th> 标签。

在这个标签助手类中,我从模型数据属性名称的“人性化”形式创建了标题名称。驼峰式属性(如“EmailAddress”)将被转换为“Email Address”,即一个简短的、可读的句子,单词之间用空格分隔。

虽然很简单,但现在这意味着任何我们想要表格标题的地方,我们将动态地从数据模型的属性名称中获取措辞,或者如果您想扩展命名,以包含元数据,从描述、简短描述甚至您自己的自定义属性中获取。

为了使用我们的新标签助手,请更新 AboutComponent.cshtml 中的 About 视图,将其从以下内容更改为

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

改为这样。

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

接下来,我们将创建另一个标签助手来帮助生成表格。这将用于显示和格式化表格单元格中的数据。在 Helpers 文件夹中创建另一个新类,名为 TabCDTagHelper.cs 并将其更新为如下内容

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

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

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

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

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

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

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

到目前为止,即使我们只在标签助手支持少量数据类型,也已经有一些通用的内容生成了应该被重构到公共方法中的 Angular 数据绑定代码。

在这些标签助手类(TagDdTagHelper.cs 和现在 TabCDTagHelper.cs )中,以下代码(或多或少变量名)是重复的

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

            pTag.InnerHtml.Append(dataBindExpression);

更新 TagHelperHelpers.cs 类以添加一个新方法

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

            return dataBindExpression;
        }

然后,在 TagDdTagHelper.cs 中,替换此代码

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

            pTag.InnerHtml.Append(dataBindExpression);

为以下内容:

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

然后在 TabCDTagHelper.cs 中,替换此代码

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

为以下内容:

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

现在,当我们想要添加例如 datetimepicker 或其他自定义控件时,我们可以在一个地方进行更改。

最后,我们也应该能够用我们新的 <td> 自定义标签助手替换我们关于组件视图 AboutComponent.cshtml 中的纯手工编写的标记。您有这段代码的地方

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

将其替换为

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

再次重建并按 Ctrl-F5,您应该会看到您的新页面已完成。

供参考,完整的 TagHelperHelpers.cs 代码如下

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

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

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

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

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

            return dataBindExpression;
        }
    }
}

完整的 AboutComponent.cshtml 视图在这里

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

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

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

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

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

并且完整的 TagHelperHelpers.cs 类是

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

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

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

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

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

            return dataBindExpression;
        }
    }
}

如果需要,您还可以通过本篇文章附带的,或在 Github 上 第 5 部分的源代码 中获取副本。

关注点

() 在编写代码时,我学到了什么有趣/好玩/令人恼火的东西?

() 我再次意识到,永远不要低估一个简单的错误带来的巨大麻烦。一个多余的引号在属性 <div class="xxx""> 中会浪费大量时间。
然后zone.js 会给出一些晦涩的错误,可能将您引向错误的方向。如果第一次不成功,请检查您的 HTML 是否干净!

我还非常希望围绕标签助手构建一系列后端单元测试,以确保它们在与前端隔离的情况下生成干净的代码并生成我想要的的代码。
我仍然可以在网络选项卡中看到发送的内容 - 但我讨厌调试,我更喜欢测试驱动/测试优先。因此,为了及时发布本系列,而不被创建使用早期发布测试工具的进一步测试所阻碍,并且没有额外的代码可能使系列复杂化 - 这进一步强化了 TDD 的好处。 IMO,它确实从长远来看节省了时间,并且据我所见,生成了更干净的代码。

() 有什么特别聪明或狂野或异想天开的?

() 我觉得越有机会从后端数据模型代码生成更多前端代码,我就越觉得令人兴奋,它提供了更具扩展性的代码,而且痛苦更少。而且是面向未来的(在可能的情况下),围绕控件更改更容易重构。

历史

© . All rights reserved.