四、表格,以及如何验证表格

在本章中,我们将了解用户输入元素(如inputselecttextarea)的数据绑定工作原理。我们还将了解 Fetch 客户端在处理比简单的 GET 请求更复杂的场景时是如何工作的,例如带有 JSON 主体的 POST 或 PUT 请求,或者将文件上载到服务器的请求。

此外,我们将看到如何使用aurelia-validation插件验证表单。

最后,我们将讨论使用aurelia-dialog插件创建复杂表单的各种策略,从内联列表版到模态版窗口。

绑定到表单输入

Aurelia 支持双向绑定到所有官方 HTML5 用户输入元素。其中一些非常简单易用,例如text input,我们在前面章节的许多示例中已经探讨了这一点。其他一些,如单选按钮或复选框,则不那么简单。让我们把它们都检查一遍。

以下章节中的代码片段摘自chapter-4/samples/binding-forms

选择元素

对于一个select元素,我们通常绑定到它的value属性,并且经常使用repeat.for来呈现它的option元素:

<template> 
  <select value.bind="selectedCountry"> 
    <option>Select your country</option> 
    <option repeat.for="country of countries"  
            value.bind="country">${country}</option> 
  </select> 
</template> 

当然,select元素的value属性默认是双向绑定的,所以所选option元素的value属性将被赋给绑定到selectvalue的表达式。在本例中,selectedCountry属性将被指定选定的country值。

option元素的value属性只需要字符串值。在前面的示例中,countries属性是一个字符串数组,因此每个optionvalue都绑定到一个字符串。要呈现绑定到任何其他类型的值(例如对象)的option,必须使用特殊的model属性:

<template> 
  <select value.bind="selectedCulture"> 
    <option>Select your culture</option> 
    <option repeat.for="culture of cultures"  
            model.bind="culture">${culture.name}</option> 
  </select> 
</template> 

这里,selectedCulture属性将被分配给所选的culture对象,因为cultures属性是一个对象数组。

或者,如果您需要选择一个键属性,例如 ID,而不是整个数组项,那么您仍然可以在option元素上使用value属性,因为键属性是字符串值:

<template> 
  <select value.bind="selectedCultureIsoCode"> 
    <option>Select your culture</option> 
    <option repeat.for="culture of cultures"  
            value.bind="culture.isoCode">${culture.name}</option> 
  </select> 
</template> 

在本例中,所选optionvalue绑定到对应项目的isoCode属性,该属性为字符串,因此所选项目的该属性将被分配给selectedCultureIsoCode

当然,在渲染过程中,将计算绑定到select属性value的表达式的值,如果任何option具有匹配的valuemodel属性,则该option将被渲染为选中。

多选

select元素具有multiple属性时,绑定到其value属性的表达式应为数组:

<template> 
  <select value.bind="selectedCountries" multiple> 
    <option repeat.for="country of countries"  
            value.bind="country">${country}</option> 
  </select> 
</template> 

此处,所选选项的值将添加到selectedCountries数组中。

当用户选择项目时,所选值始终附加在选择数组的末尾。

当然,在将非字符串值数组呈现到多选列表中时,同样的规则也适用;每个数组项都应该绑定到其option``model属性:

<template> 
  <select value.bind="selectedCultures" multiple> 
    <option repeat.for="culture of cultures"  
            model.bind="culture">${culture.name}</option> 
  </select> 
</template> 

这里,所选的culture对象将添加到selectedCultures数组中。

另一种方法是使用键字符串属性,也适用于多选:

<template> 
  <select value.bind="selectedCulturesIsoCodes" multiple> 
    <option repeat.for="culture of cultures"  
            value.bind="culture.isoCode">${culture.name}</option> 
  </select> 
</template> 

在本例中,所选culture对象的isoCode属性将添加到selectedCulturesIsoCodes数组中,该数组是字符串数组。

配对者

当使用model属性时,可能会发生分配给selectvalue的对象与分配给option``model的对象具有相同的身份,但不是相同的实例。在这种情况下,Aurelia 将无法渲染所选的正确option

matcher属性是针对这种场景设计的:

<template> 
  <select value.bind="selectedCulture" matcher.bind="matchCulture"> 
    <option>Select your culture</option> 
    <option repeat.for="culture of cultures"  
            model.bind="culture">${culture.name}</option> 
  </select> 
</template> 

在这里,当试图找到应该选择哪一个option时,select元素将把相等比较委托给matchCulture函数,该函数应该是这样的:

export class ViewModel { 
  matchCulture = (culture1, culture2) => culture1.isoCode === culture2.isoCode; 
} 

这里,此函数需要两个区域性对象,它们可能具有相同的标识并表示相同的区域性。如果两个对象具有相同的标识,则返回true,否则返回false

输入元素

在大多数情况下,绑定到input元素很简单,但实际上取决于type属性。例如,对于text输入,value属性默认为双向绑定,可用于获取用户的输入:

<template> 
  <input type="text" value.bind="title"> 
</template> 

这里,title属性的初始值将显示在input中,用户对input值所做的任何更改也将应用于title属性。同样,对title属性的任何更改也将应用于inputvalue

对于大多数其他类型的inputcolordateemailnumberpasswordtelurl的用法相同,仅举几个例子。但也有一些特殊情况,如下所述。

文件选择器

input元素的type属性为file时,将其files属性作为属性公开。默认情况下,它使用双向绑定:

<template> 
  <input type="file" accepts="image/*" files.bind="images"> 
</template> 

在本例中,input``files属性绑定到视图模型的images属性。当用户选择一个文件时,images被分配一个FileList对象,其中包含作为单个File对象的所选文件。如果input具有multiple属性,则用户可以选择多个文件,生成的FileList对象包含用户选择的数量相同的File对象。

FileListFile类是 HTML5 文件 API 的一部分,可以与 Fetch API 一起使用,将用户选择的文件发送到服务器。在本章后面的联系人应用中构建照片编辑组件时,我们将看到一个例子。

Mozilla 开发者网络有大量关于文件 API 的文档。有关FileList类的详细信息,请访问https://developer.mozilla.org/en-US/docs/Web/API/FileList

单选按钮

select元素的option类似,单选按钮可以使用valuemodel属性来指定选择按钮时使用的值。value属性只需要字符串值,因此model属性必须用于任何其他类型的值。

此外,单选按钮可以将其checked属性(默认情况下是双向的)绑定到一个表达式,该表达式在选中时将被指定为按钮的valuemodel

<template> 
  <label repeat.for="country of countries"> 
    <input type="radio" name="countries" value.bind="country"  
           checked.bind="selectedCountry"> 
    ${country} 
  </label> 
</template> 

这里,使用名为countries的字符串数组呈现一组单选按钮。所选单选按钮的country绑定到value属性,将分配给selectedCountry属性。

option元素一样,当绑定到非字符串的值时,应该使用model属性而不是value

<template> 
  <label repeat.for="culture of cultures"> 
    <input type="radio" name="cultures" model.bind="culture"  
              checked.bind="selectedCulture"> 
    ${culture.name} 
  </label> 
</template> 

这里,一组单选按钮是使用culture对象数组渲染的。所选单选按钮的culture绑定到model属性,将分配给selectedCulture属性。

select元素类似,使用model属性的单选按钮也可以使用matcher属性自定义相等比较逻辑。

前面的所有示例都使用绑定到数组的repeat.for来呈现单选按钮的动态列表。例如,如果需要呈现单选按钮的静态列表,并且预期的输出是布尔值,该怎么办?在这种情况下,不需要迭代数组:

<template> 
  <h4>Do you speak more than one language?</h4> 
  <label> 
    <input type="radio" name="isMultilingual" model.bind="null"  
           checked.bind="isMultilingual">  
    That's none of your business 
  </label> 
  <label> 
    <input type="radio" name="isMultilingual" model.bind="true"  
           checked.bind="isMultilingual"> 
    Yes 
  </label> 
  <label> 
    <input type="radio" name="isMultilingual" model.bind="false"  
           checked.bind="isMultilingual"> 
    No 
  </label> 
</template> 

在本例中,呈现单选按钮的静态列表,每个单选按钮使用其model属性绑定到不同的标量值。他们的checked属性被绑定到isMultilingual属性,该属性将被分配nulltruefalse,具体取决于选择的按钮。

当然,在渲染过程中,如果绑定到组中按钮的checked属性的表达式具有与按钮的valuemodel属性之一匹配的值,则该按钮将被渲染为选中。

复选框

复选框列表的典型用法类似于具有multiple属性的select元素。每个input元素都有valuemodel属性。另外,一个checked属性需要绑定到一个数组,所有选中的inputvaluemodel都将被添加到该数组中:

<template> 
  <label repeat.for="country of countries"> 
    <input type="checkbox" value.bind="country"  
           checked.bind="selectedCountries"> 
    ${country} 
  </label> 
</template> 

这里,使用名为countries的字符串数组呈现一组复选框。所选复选框中绑定到value属性的country将添加到selectedCountries数组中。

option元素或单选按钮一样,value属性只需要字符串值。当绑定到任何其他类型的值时,应使用model属性:

<template> 
  <label> 
    <input type="checkbox" model.bind="culture"  
           checked.bind="selectedCultures"> 
    ${culture.name} 
  </label> 
</template> 

这里,一组复选框是使用一组culture对象呈现的。使用model属性绑定的所选复选框的culture将添加到selectedCultures数组中。

select元素和单选按钮类似,使用model属性的复选框也可以使用matcher属性自定义相等比较逻辑。

当然,如果在渲染对象数组时,选择的值是某种字符串 ID,则仍然可以使用value属性:

<template> 
  <label> 
    <input type="checkbox" value.bind="culture.isoCode"  
           checked.bind="selectedCulturesIsoCodes"> 
    ${culture.name} 
  </label> 
</template> 

这里,一组复选框是使用一组culture对象呈现的。绑定到value属性的所选复选框cultureisoCode属性将添加到字符串的selectedCulturesIsoCodes数组中。

当然,在渲染过程中,如果绑定到checked属性的数组包含绑定到valuemodel属性的值,则此复选框将被渲染为选中。

或者,复选框可以绑定到不同的布尔表达式,而不是单个数组。这可以通过省略任何valuemodel属性来实现:

<template> 
  <label> 
    <input type="checkbox" checked.bind="speaksFrench">French 
  </label> 
  <label> 
    <input type="checkbox" checked.bind="speaksEnglish">English 
  </label> 
  <label> 
    <input type="checkbox" checked.bind="speaksGerman">German 
  </label> 
</template> 

在本例中,每个checkbox都绑定到一个不同的属性,该属性将被分配truefalse,具体取决于复选框是否选中。

文本区

绑定到textarea元素与绑定到text``input元素相同:

<template> 
  <textarea value.bind="text"></textarea> 
</template> 

这里,text属性的初始值将显示在textarea中,并且由于textareavalue属性的绑定默认为双向,因此用户对textarea内容所做的所有修改都将反映在text属性上。

禁用一个元素

禁用inputselecttextareabutton元素只是绑定到其disabled属性的问题:

<template> 
  <input type="text" disabled.bind="isSending"> 
  <button disabled.bind="isSending">Send</button> 
</template> 

这里,当isSendingtrue时,inputbutton元素都将被禁用。

使元素只读

类似地,使inputtextarea元素只读只是绑定到其readonly属性的问题:

<template> 
  <input type="text" readonly.bind="!canEdit"> 
</template> 

这里,canEditfalse时,input为只读。

在我们的申请中添加表格

现在我们知道了如何使用用户输入元素,我们可以在联系人管理应用中添加表单来创建和编辑联系人。

新增航线

我们需要添加三个新路由:一个用于创建新联系人,另一个用于编辑现有联系人,最后一个用于上传联系人照片。让我们将它们添加到根组件中:

src/app.js文件如下:

export class App { 
  configureRouter(config, router) { 
    this.router = router; 
    config.title = 'Learning Aurelia'; 
    config.map([ 
      { route: '', redirect: 'contacts' }, 
      { route: 'contacts', name: 'contacts', moduleId:        'contact-list',  
        nav: true, title: 'Contacts' }, 
      { route: 'contacts/new', name: 'contact-creation',  
        moduleId: 'contact-edition', title: 'New contact' }, 
      { route: 'contacts/:id', name: 'contact-details',  
        moduleId: 'contact-details' }, 
      { route: 'contacts/:id/edit', name: 'contact-edition',  
        moduleId: 'contact-edition' }, 
      { route: 'contacts/:id/photo', name: 'contact-photo', 
        moduleId: 'contact-photo' }, 
    ]); 
    config.mapUnknownRoutes('not-found'); 
  } 
} 

这三条新路由在前面的代码段中突出显示。

定位在这里很重要。由于contact-creation路由各自的route属性,因此将其置于contact-details路由之前。当试图在 URL 更改时找到匹配的路由时,路由器会按照路由定义的顺序深入查看路由定义。由于contact-details的模式匹配任何以contacts/开始并后跟被解释为参数的第二部分的路径,因此路径contacts/new匹配该模式,因此如果稍后定义contact-creation路由,则无法访问该路径,而将使用等于newid参数来访问contact-details路由。

一个更好的替代方法是改变路线的顺序,这样就不会发生碰撞。例如,我们可以将contact-details的模式更改为类似于contacts/:id/details的模式。在这种情况下,路线的顺序将不再重要。

您可能已经注意到,其中两条新路线具有相同的moduleId。这是因为我们将使用相同的组件创建新联系人和编辑现有联系人。

为新路线添加链接

下一步是添加指向我们刚才添加的路线的链接。我们将首先在contact-list组件中添加到contact-creation路由的链接:

src/contact-list.html

 <template> 
  <section class="container"> 
    <h1>Contacts</h1> 

    <div class="row"> 
      <div class="col-sm-1"> 
        <a route-href="route: contact-creation" class= "btn btn-primary"> 
          <i class="fa fa-plus-square-o"></i> New 
        </a> 
      </div> 
      <div class="col-sm-2"> 
        <!-- Search box omitted for brevity --> 
      </div> 
    </div> 
    <!--  Contact list omitted for brevity --> 
  </section> 
</template> 

这里我们添加了一个a元素,并利用route-href属性来呈现contact-creation路由的 URL。

我们还需要添加到contact-photocontact-edition路线的链接。我们将在contact-details组件中执行此操作:

src/contact-details.html

 <template> 
  <section class="container"> 
    <div class="row"> 
      <div class="col-sm-2"> 
        <a route-href="route: contact-photo; params.bind:
          { id: contact.id }"  
           > 
          <img src.bind="contact.photoUrl" class= "img-responsive" alt="Photo"> 
        </a> 
      </div> 
      <div class="col-sm-10"> 
        <template if.bind="contact.isPerson"> 
          <h1>${contact.fullName}</h1> 
          <h2>${contact.company}</h2> 
        </template> 
        <template if.bind="!contact.isPerson"> 
          <h1>${contact.company}</h1> 
        </template> 
        <a class="btn btn-default" route-href="route:
          contact-edition;  
          params.bind: { id: contact.id }"> 
          <i class="fa fa-pencil-square-o"></i> Modify 
        </a> 
      </div> 
    </div> 
    <!-- Rest of template omitted for brevity --> 
  </section> 
</template> 

在这里,我们首先重构显示fullNamecompany的模板(如果联系人是个人),方法是添加一个封闭的div并将col-sm-10CSS 类从标题移动到这个div

接下来,我们使用联系人的id作为参数,将显示联系人照片的img元素包装在导航到contact-photo路线的锚中。

最后,我们使用联系人的id作为参数,添加另一个指向contact-edition路线的锚。

更新模型

为了重用代码,我们将坚持使用Contact类,并在表单组件中使用它。我们还将为电话号码、电子邮件地址、地址和社交档案创建类,这样我们的contact-edition组件就不必知道如何创建这些对象的空实例的细节。

我们需要添加创建模型的空实例的能力,并将其所有属性初始化为适当的默认值。因此,我们将为模型类上的所有属性添加默认值。

最后,我们需要更新Contact``fromObject工厂方法,以便所有列表项都正确映射到我们模型类的实例。

src/models.js

export class PhoneNumber { 
  static fromObject(src) { 
    return Object.assign(new PhoneNumber(), src); 
  } 

  type = 'Home'; 
  number = ''; 
} 

export class EmailAddress { 
  static fromObject(src) { 
    return Object.assign(new EmailAddress(), src); 
  } 

  type = 'Home'; 
  address = ''; 
} 

export class Address { 
  static fromObject(src) { 
    return Object.assign(new Address(), src); 
  } 

  type = 'Home'; 
  number = ''; 
  street = ''; 
  postalCode = ''; 
  city = ''; 
  state = ''; 
  country = ''; 
} 

export class SocialProfile { 
  static fromObject(src) { 
    return Object.assign(new SocialProfile(), src); 
  } 

  type = 'GitHub'; 
  username = ''; 
} 

export class Contact { 
  static fromObject(src) { 
    const contact = Object.assign(new Contact(), src); 
    contact.phoneNumbers = contact.phoneNumbers 
      .map(PhoneNumber.fromObject); 
    contact.emailAddresses = contact.emailAddresses 
      .map(EmailAddress.fromObject); 
    contact.addresses = contact.addresses 
      .map(Address.fromObject); 
    contact.socialProfiles = contact.socialProfiles 
      .map(SocialProfile.fromObject); 
    return contact; 
  } 

  firstName = ''; 
  lastName = ''; 
  company = ''; 
  birthday = ''; 
  phoneNumbers = []; 
  emailAddresses = []; 
  addresses = []; 
  socialProfiles = []; 
  note = ''; 

  // Omitted snippet... 
} 

在这里,我们首先为 aPhoneNumber、aEmailAddress、aAddress和 aSocialProfile添加类。这些类中的每一个都有一个静态的fromObject工厂方法,其属性用默认值正确初始化。

接下来,我们添加一个用默认值初始化的Contact的属性,并更改其fromObject工厂方法,以便列表项正确映射到它们各自的类。

创建表单组件

现在我们可以创建新的contact-edition组件。如前所述,此组件将用于创建和编辑。它将能够通过检查在其activate回调方法中是否接收到id参数来检测是否用于创建新联系人或编辑现有联系人。实际上,contact-creation路由的模式没有定义任何参数,因此当我们的表单组件被此路由激活时,它不会接收任何id参数。另一方面,由于contact-edition路由的模式确实定义了一个id参数,因此当此路由激活时,我们的表单组件将接收该参数。

我们可以这样做,因为在我们的联系人管理应用的范围内,创建和编辑过程几乎是相同的。但是,在许多情况下,使用单独的组件进行创建和编辑可能是更好的设计。

激活视图模型

我们首先从视图模型和activate回调方法开始:

src/contact-edition.js

import {inject} from 'aurelia-framework'; 
import {ContactGateway} from './contact-gateway'; 
import {Contact} from './models'; 

@inject(ContactGateway) 
export class ContactEdition { 
  constructor(contactGateway) { 
    this.contactGateway = contactGateway; 
  } 

  activate(params, config) { 
    this.isNew = params.id === undefined; 
    if (this.isNew) { 
      this.contact = new Contact(); 
    } 
    else { 
      return this.contactGateway.getById(params.id).then(contact => { 
        this.contact = contact; 
        config.navModel.setTitle(contact.fullName); 
      }); 
    } 
  } 
} 

在这里,我们首先将ContactGateway类的一个实例注入到我们的视图模型中。然后,在activate回调方法中,我们首先根据id参数的存在定义一个isNew属性。我们的组件将使用此属性来了解它是否用于创建新联系人或编辑现有联系人。

接下来,基于这个isNew属性,我们初始化组件。如果我们正在创建一个新联系人,那么我们只需创建一个contact属性并为其分配一个新的空Contact实例;否则,我们使用ContactGateway根据id参数检索正确的联系人,Promise解析时,将Contact实例分配给contact属性,并将文档标题设置为联系人的fullName属性。

激活周期完成后,视图模型将有一个正确初始化为Contact对象的contact属性和一个指示联系人是新联系人还是现有联系人的isNew属性。

构建表单布局

接下来,让我们构建模板来显示表单。这个模板非常大,我将把它分解成几个部分,这样你就可以逐步构建它,并在每一步进行测试。

模板由一个标题和一个form元素组成,该元素将包含模板的其余部分:

src/contact-edition.html

 <template> 
  <section class="container"> 
    <h1 if.bind="isNew">New contact</h1> 
    <h1 if.bind="!isNew">Contact #${contact.id}</h1> 

    <form class="form-horizontal"> 
      <!-- The rest of the template goes in here --> 
    </form> 
  </section> 
</template> 

在标题中,我们使用isNew属性显示静态标题,告诉用户他正在创建新联系人,或者显示动态标题,显示正在编辑的联系人的id

编辑标量属性

接下来,我们将添加包含输入元素的块,以编辑联系人的firstNamelastNamecompanybirthdaynote,在前面代码段中定义的form元素中:

<div class="form-group"> 
  <label class="col-sm-3 control-label">First name</label> 
  <div class="col-sm-9"> 
    <input type="text" class="form-control" value.bind="contact.firstName"> 
  </div> 
</div> 

<div class="form-group"> 
  <label class="col-sm-3 control-label">Last name</label> 
  <div class="col-sm-9"> 
    <input type="text" class="form-control" value.bind="contact.lastName"> 
  </div> 
</div> 

<div class="form-group"> 
  <label class="col-sm-3 control-label">Company</label> 
  <div class="col-sm-9"> 
    <input type="text" class="form-control" value.bind="contact.company"> 
  </div> 
</div> 

<div class="form-group"> 
  <label class="col-sm-3 control-label">Birthday</label> 
  <div class="col-sm-9"> 
    <input type="date" class="form-control" value.bind="contact.birthday"> 
  </div> 
</div> 

<div class="form-group"> 
  <label class="col-sm-3 control-label">Note</label> 
  <div class="col-sm-9"> 
    <textarea class="form-control" value.bind="contact.note"></textarea> 
  </div> 
</div> 

在这里,我们只需为每个要编辑的属性定义一个form-group。前三个属性分别绑定到一个text input元素。此外,birthday属性绑定到date输入,这使得编辑日期更容易——当然,对于支持日期的浏览器来说,note属性绑定到textarea元素。

编辑电话号码

在此之后,我们需要为列表添加编辑器。由于每个列表中包含的数据不是很复杂,我们将呈现内联编辑器,因此用户可以用最少的点击次数直接编辑任何项目的任何字段。

在本章后面,我们将使用对话框讨论更复杂的编辑模型。

让我们从电话号码开始:

<hr> 
<div class="form-group" repeat.for="phoneNumber of contact.phoneNumbers"> 
  <div class="col-sm-2 col-sm-offset-1"> 
    <select value.bind="phoneNumber.type" class="form-control"> 
      <option value="Home">Home</option> 
      <option value="Office">Office</option> 
      <option value="Mobile">Mobile</option> 
      <option value="Other">Other</option> 
    </select> 
  </div> 
  <div class="col-sm-8"> 
    <input type="tel" class="form-control" placeholder="Phone number"  
           value.bind="phoneNumber.number"> 
  </div> 
  <div class="col-sm-1"> 
    <button type="button" class="btn btn-danger"  
            click.delegate="contact.phoneNumbers.splice($index, 1)"> 
      <i class="fa fa-times"></i>  
    </button> 
  </div> 
</div> 
<div class="form-group"> 
  <div class="col-sm-9 col-sm-offset-3"> 
    <button type="button" class="btn btn-default" click.delegate="contact.addPhoneNumber()"> 
      <i class="fa fa-plus-square-o"></i> Add a phone number 
    </button> 
  </div> 
</div> 

此电话号码列表编辑器可以分解为多个部分,其中最重要的部分会突出显示。首先,对联系人的phoneNumbers数组中的每个phoneNumber重复form-group

对于每个phoneNumber,我们定义一个select元素,其value绑定到phoneNumbertype属性,以及一个tel input,其value绑定到phoneNumbernumber属性。此外,我们还定义了一个button,它的click事件使用当前的$index将电话号码从contactphoneNumbers数组中拼接出来,正如您在上一章中所记得的,当前的$index通过repeat属性添加到绑定上下文中。

最后,在电话号码列表之后,我们定义了一个button,其click事件调用contact中的addPhoneNumber方法。

添加缺失的方法

我们在前面的模板中添加的按钮之一调用了一个尚未定义的方法。让我们将此方法添加到Contact类中:

src/models.js

//Snippet... 
export class Contact { 
  //Snippet... 
  addPhoneNumber() { 
    this.phoneNumbers.push(new PhoneNumber()); 
  } 
} 

此代码片段中的第一个方法用于将空电话号码添加到列表中,只需在phoneNumbers数组中推送一个新的PhoneNumber实例。

编辑其他列表

其他列表、电子邮件地址、地址和社交档案的模板非常相似。只有正在编辑的字段会发生更改,但主要概念——重复表单组,每个项目都有一个删除按钮,列表末尾有一个添加按钮——是相同的。

让我们从emailAddresses开始:

<hr> 
<div class="form-group" repeat.for="emailAddress of contact.emailAddresses"> 
  <div class="col-sm-2 col-sm-offset-1"> 
    <select value.bind="emailAddress.type" class="form-control"> 
      <option value="Home">Home</option> 
      <option value="Office">Office</option> 
      <option value="Other">Other</option> 
    </select> 
  </div> 
  <div class="col-sm-8"> 
    <input type="email" class="form-control" placeholder="Email address"  
           value.bind="emailAddress.address"> 
  </div> 
  <div class="col-sm-1"> 
    <button type="button" class="btn btn-danger"  
            click.delegate="contact.emailAddresses.splice($index, 1)"> 
      <i class="fa fa-times"></i>  
    </button> 
  </div> 
</div> 
<div class="form-group"> 
  <div class="col-sm-9 col-sm-offset-3"> 
    <button type="button" class="btn btn-primary"  
            click.delegate="contact.addEmailAddress()"> 
      <i class="fa fa-plus-square-o"></i> Add an email address 
    </button> 
  </div> 
</div> 

此模板与电话号码模板非常相似。主要区别在于可用类型不完全相同,input``typeemail

正如您所想象的,地址编辑器将稍微大一点:

<hr> 
<div class="form-group" repeat.for="address of contact.addresses"> 
  <div class="col-sm-2 col-sm-offset-1"> 
    <select value.bind="address.type" class="form-control"> 
      <option value="Home">Home</option> 
      <option value="Office">Office</option> 
      <option value="Other">Other</option> 
    </select> 
  </div> 
  <div class="col-sm-8"> 
    <div class="row"> 
      <div class="col-sm-4"> 
        <input type="text" class="form-control" placeholder="Number"  
               value.bind="address.number"> 
      </div> 
      <div class="col-sm-8"> 
        <input type="text" class="form-control" placeholder="Street"  
               value.bind="address.street"> 
      </div> 
    </div> 
    <div class="row"> 
      <div class="col-sm-4"> 
        <input type="text" class="form-control" placeholder="Postal code"  
               value.bind="address.postalCode"> 
      </div> 
      <div class="col-sm-8"> 
        <input type="text" class="form-control" placeholder="City"  
               value.bind="address.city"> 
      </div> 
    </div> 
    <div class="row"> 
      <div class="col-sm-4"> 
        <input type="text" class="form-control" placeholder="State"  
               value.bind="address.state"> 
      </div> 
      <div class="col-sm-8"> 
        <input type="text" class="form-control" placeholder="Country"  
               value.bind="address.country"> 
      </div> 
    </div> 
  </div> 
  <div class="col-sm-1"> 
    <button type="button" class="btn btn-danger"  
            click.delegate="contact.addresses.splice($index, 1)"> 
      <i class="fa fa-times"></i>  
    </button> 
  </div> 
</div> 
<div class="form-group"> 
  <div class="col-sm-9 col-sm-offset-3"> 
    <button type="button" class="btn btn-primary"  
            click.delegate="contact.addAddress()"> 
      <i class="fa fa-plus-square-o"></i> Add an address 
    </button> 
  </div> 
</div> 

这里,左边的部分包含六个不同的输入,允许我们编辑地址的各种文本属性。

此时,您可能已经知道社交档案的模板是什么样的:

<hr> 
<div class="form-group" repeat.for="profile of contact.socialProfiles"> 
  <div class="col-sm-2 col-sm-offset-1"> 
    <select value.bind="profile.type" class="form-control"> 
      <option value="GitHub">GitHub</option> 
      <option value="Twitter">Twitter</option> 
    </select> 
  </div> 
  <div class="col-sm-8"> 
    <input type="text" class="form-control" placeholder="Username"  
           value.bind="profile.username"> 
  </div> 
  <div class="col-sm-1"> 
    <button type="button" class="btn btn-danger"  
            click.delegate="contact.socialProfiles.splice($index, 1)"> 
      <i class="fa fa-times"></i>  
    </button> 
  </div> 
</div> 
<div class="form-group"> 
  <div class="col-sm-9 col-sm-offset-3"> 
    <button type="button" class="btn btn-primary"  
            click.delegate="contact.addSocialProfile()"> 
      <i class="fa fa-plus-square-o"></i> Add a social profile 
    </button> 
  </div> 
</div> 

当然,为每个列表添加项目的方法需要添加到Contact类中:

src/models.js

//Omitted snippet... 
export class Contact { 
  //Omitted snippet... 
  addEmailAddress() { 
    this.emailAddresses.push(new EmailAddress()); 
  } 

  addAddress() { 
    this. addresses.push(new Address()); 
  } 

  addSocialProfile() { 
    this.socialProfiles.push(new SocialProfile()); 
  } 
} 

如您所见,这些方法与我们之前为电话号码编写的方法几乎相同。此外,每个列表的模板片段也基本相同。所有这些冗余都要求重构。我们将在第 5 章制作可重用组件中看到,我们如何将常见行为和模板片段提取到一个组件中,我们将重用该组件来管理每个列表。

保存和取消

我们的表单(至少在视觉上)要完成的最后一件事是在所包含的form元素的末尾有一个保存和一个取消按钮:

//Omitted snippet... 
<form class="form-horizontal" submit.delegate="save()"> 
  //Omitted snippet... 
  <div class="form-group"> 
      <div class="col-sm-9 col-sm-offset-3"> 
        <button type="submit" class="btn btn-success">Save</button> 
        <a if.bind="isNew" class="btn btn-danger"  
           route-href="route: contacts">Cancel</a> 
        <a if.bind="!isNew" class="btn btn-danger"  
           route-href="route: contact-details;  
           params.bind: { id: contact.id }">Cancel</a> 
      </div> 
    </div> 
</form> 

首先,我们将对save方法的调用绑定到form元素的submit事件,然后添加最后一个form-group,其中包含一个名为Save.submit按钮

接下来,我们添加两个Cancel链接:一个在创建新联系人时显示以导航回联系人列表,另一个在编辑现有联系人时显示以导航回联系人的详细信息。

我们还需要将save方法添加到视图模型中。这个方法最终将委托给ContactGateway但是,为了测试到目前为止我们所做的一切是否有效,让我们编写一个该方法的虚拟版本:

save() { 
  alert(JSON.stringify(this.contact)); 
} 

此时,您应该能够运行应用并尝试创建或编辑联系人。单击保存按钮时,您应该会看到一个显示联系人的警报,序列化为 JSON。

使用 fetch 发送数据

我们现在可以向ContactGateway类添加创建和更新联系人的方法:

src/contact-gateway.js

//Omitted snippet... 
import {HttpClient, json} from 'aurelia-fetch-client'; 
//Omitted snippet... 
export class ContactGateway { 
  //Omitted snippet... 
  create(contact) { 
    return this.httpClient.fetch('contacts',  
      { method: 'POST', body: json(contact) }); 
  } 

  update(id, contact) { 
    return this.httpClient.fetch(`contacts/${id}`,  
      { method: 'PUT', body: json(contact) }); 
  } 
} 

首先要做的是import来自fetch-clientjson函数。此函数将任何 JS 值作为参数,并返回一个包含序列化为 JSON 的接收参数的Blob对象。

接下来,我们添加一个create方法,该方法将contact作为参数,并调用 HTTP 客户端的fetch方法,将要调用的相对 URL 传递给它,后面是一个配置对象。此对象包含将分配给基础Request对象的属性。在这里,我们指定一个method属性,告诉客户机执行POST请求,并指示请求的body将被序列化为 JSON 的contact。最后,fetch方法返回一个Promise,这是我们新的create方法返回的,因此调用方可以在请求完成时做出反应。

update方法非常相似。第一个区别是参数:首先需要触点的id,然后是contact对象本身。第二,fetch呼叫略有不同;它使用PUT方法向不同的 URL 发送请求,但其主体是相同的。

获取Requestbody应为BlobBufferSourceFormDataURLSearchParamsUSVString对象。有关这方面的文档可在 Mozilla 开发者网络上找到,网址为https://developer.mozilla.org/en-US/docs/Web/API/Request/Request

为了测试我们的新方法是否有效,让我们将contact-edition组件视图模型中的虚拟save方法替换为真实处理:

//Omitted snippet... 
import {Router} from 'aurelia-router'; 

@inject(ContactGateway, Router) 
export class ContactEdition { 
  constructor(contactGateway, router) { 
    this.contactGateway = contactGateway; 
    this.router = router; 
  } 

  // Omitted snippet... 
  save() { 
    if (this.isNew) { 
      this.contactGateway.create(this.contact)  
        .then(() => this.router.navigateToRoute('contacts')); 
    } 
    else { 
      this.contactGateway.update(this.contact.id, this.contact)  
        .then(() => this.router.navigateToRoute('contact-details',  
                    { id: this.contact.id })); 
    } 
  } 
} 

这里,我们首先导入Router并在视图模型中注入它的一个实例。接下来,我们改变save方法的主体:如果组件正在创建新的联系人,我们首先从ContactGateway调用create方法,将contact对象传递给它,然后在Promise解析时导航回contacts路径;否则,当组件编辑现有联系人时,我们首先调用ContactGatewayupdate方法,将联系人的idcontact对象传递给它,然后在Promise解析时导航回联系人的详细信息。

此时,您应该能够创建或更新联系人。但是,一些创建或更新请求可能返回 400 错误请求状态的响应。不要惊慌;由于 HTTP 端点执行一些验证,而我们的表单此时不执行验证,因此,例如,如果您将某些字段留空,则可能会发生这种情况。我们将在本章后面的表单中添加验证,这将防止此类错误。

上传联系人照片

现在我们可以创建和编辑联系人了,让我们添加一个组件来上传其照片。该组件将命名为contact-photo,并将由同名路由激活,我们在本章前面已经添加了同名路由。

该组件将使用一个file input元素让用户在其文件系统上选择一个图像文件,并将利用 HTML5 文件 API 和 Fetch 客户端将包含所选图像文件的 PUT 请求发送到我们的 HTTP 端点。

建立模板

该组件的模板简单地重用了我们已经介绍过的几个概念:

src/contact-photo.html

 <template> 
  <section class="container"> 
    <h1>${contact.fullName}</h1> 

    <form class="form-horizontal" submit.delegate="save()"> 
      <div class="form-group"> 
        <label class="col-sm-3 control-label" for="photo">Photo</label> 
        <div class="col-sm-9"> 
          <input type="file" id="photo" accept="image/*"  
                 files.bind="photo"> 
        </div> 
      </div> 

      <div class="form-group"> 
        <div class="col-sm-9 col-sm-offset-3"> 
          <button type="submit" class="btn btn-success">Save</button> 
          <a class="btn btn-danger" route-href="route: contact-details;  
             params.bind: { id: contact.id }">Cancel</a> 
        </div> 
      </div> 
    </form> 
  </section> 
</template> 

这里,我们首先显示联系人的fullName作为页面标题。然后,在submit事件触发save方法的form元素中,我们添加file input和按钮保存取消照片上传。file input具有accept属性,强制浏览器的文件选择对话框仅显示图像文件,其files属性绑定到photo属性。

创建视图模型

视图模型看起来很像contact-edition视图模型,至少在比较导入、构造函数和activate方法时是这样:

src/contact-photo.js

import {inject} from 'aurelia-framework'; 
import {Router} from 'aurelia-router'; 
import {ContactGateway} from './contact-gateway'; 

@inject(ContactGateway, Router) 
export class ContactPhoto { 

  constructor(contactGateway, router) { 
    this.contactGateway = contactGateway; 
    this.router = router; 
  } 

  activate(params, config) { 
    return this.contactGateway.getById(params.id).then(contact => { 
      this.contact = contact; 
      config.navModel.setTitle(this.contact.fullName); 
    }); 
  } 
  save() { 
    if (this.photo && this.photo.length > 0) { 
      this.contactGateway.updatePhoto( 
        this.contact.id,  
        this.photo.item(0) 
      ).then(() => { 
        this.router.navigateToRoute( 
          'contact-details',  
          { id: this.contact.id }); 
      }); 
    } 
  } 
} 

此视图模型期望在其构造函数中同时注入一个ContactGateway实例和一个Router实例。在其activate方法中,然后使用其id参数加载Contact实例,并使用contactfullName初始化文档标题。这与contact-edition视图模型非常相似。

然而,save方法有点不同。它首先检查是否已选择文件;如果没有,它现在什么也做不了。否则,调用ContactGateway方法的updatePhoto方法,将联系人的id和所选文件传递给它,并在Promise解析时导航回联系人的详细信息。

使用 fetch 上传文件

让照片上传功能正常工作的最后一步是ContactGateway类中的uploadPhoto方法:

src/contact-gateway.js

//Omitted snippet... 
export class ContactGateway { 
  //Omitted snippet... 
  updatePhoto(id, file) { 
    return this.httpClient.fetch(`contacts/${id}/photo`, {  
      method: 'PUT', 
      headers: { 'Content-Type': file.type }, 
      body: file 
    }); 
  } 
} 

我们 HTTP 后端的contacts/{id}/photo端点需要一个 PUT 请求,其主体是正确的Content-Type头和图像二进制文件。这正是对fetch的调用在这里所做的:它使用file参数,该参数应该是File类的实例,并使用其type属性设置Content-Type头,然后将file本身作为请求主体发送。

如前所述,File类是 HTML5 文件 API 的一部分。Mozilla 开发者网络有大量关于此 API 的文档。有关File类的详细信息,请访问https://developer.mozilla.org/en-US/docs/Web/API/File

通常,updatePhoto方法返回 HTTP 请求解析的Promise,因此调用方可以在操作完成时进行操作。

此时,您应该能够运行应用,并通过上载新的图像文件更新联系人的照片。

删除联系人

此时,我们的应用允许我们创建、读取和更新联系人。显然,创建、阅读、更新、删除CRUD)中的一封信在此缺失:我们还不能删除联系人。让我们快速实现这个特性。

首先,让我们在联系人的details组件中添加一个删除按钮:

src/contact-details.html

 <template> 
  <section class="container"> 
    <div class="row"> 
      <div class="col-sm-2"> 
        <!-- Omitted snippet... --> 
      </div> 
      <div class="col-sm-10"> 
        <!-- Omitted snippet... --> 
        <a class="btn btn-default" route-href="route: contact-edition;  
          params.bind: { id: contact.id }"> 
          <i class="fa fa-pencil-square-o"></i> Modify 
        </a> 
        <button class="btn btn-danger" click.delegate="tryDelete()"> 
          <i class="fa fa-trash-o"></i> Delete 
        </button> 
      </div> 
    </div> 
    <!-- Rest of template omitted for brevity --> 
  </section> 
</template> 

新的删除按钮点击后会调用tryDelete方法:

src/contact-details.js

//Omitted snippet... 
export class ContactDetails { 
  //Omitted snippet... 
  tryDelete() { 
    if (confirm('Do you want to delete this contact?')) { 
      this.contactGateway.delete(this.contact.id) 
        .then(() => { this.router.navigateToRoute('contacts'); }); 
    } 
  } 
} 

tryDelete方法首先要求用户confirm删除,然后用联系人的id调用网关的delete方法。返回的Promise解析后,将导航回联系人列表。

最后,ContactGateway类的delete方法只是使用DELETEHTTP 方法对后端的正确路径执行 Fetch 调用:

src/contact-gateway.js

//Omitted snippet... 
export class ContactGateway { 
  //Omitted snippet... 
  delete(id) { 
    return this.httpClient.fetch(`contacts/${id}`, { method: 'DELETE' }); 
  } 
} 

此时,如果您点击联系人的删除按钮并批准确认对话框,您应该被重定向到联系人列表,联系人应该消失。

验证

如果在浏览器的调试控制台打开时,试图用无效的生日、空的电话号码、地址、电子邮件或社交配置文件用户名保存联系人,您将看到 HTTP 端点以 400 错误的请求响应拒绝请求。这是因为后端对正在创建或更新的联系人执行一些验证。

让远程服务执行某种形式的验证是很常见的;相反的情况实际上被认为是糟糕的体系结构,因为远程服务不应该信任其客户端的有效数据。然而,为了提供更好的最终用户体验,客户机应用通常也会执行验证。

Aurelia 提供了aurelia-validation库,该库为验证提供者定义了一个接口,以及在组件内部插入验证的各种机制。它还提供了该接口的默认实现,提供了一个简单而强大的验证机制。

让我们看看如何使用这些库来验证我们的联系人表单。

本节仅概述了aurelia-validation提供的最常见功能。事实上,这个库比这里描述的要灵活和强大得多,所以我邀请你们在读完这本书后进一步挖掘它。

安装库

要安装库,只需在项目目录中运行以下命令:

> npm install aurelia-validation --save

接下来,我们需要使这个库在应用包中可用。在aurelia_project/aurelia.json中,在build下,然后在bundles下,在名为vendor-bundle.js的捆绑包的dependencies数组中,添加以下条目:

{ 
  "name": "aurelia-validation", 
  "path": "../node_modules/aurelia-validation/dist/amd", 
  "main": "aurelia-validation" 
}, 

此配置条目将告诉 Aurelia 的捆绑程序将新安装的库包含在供应商捆绑包中。

配置

aurelia-validation库需要进行一些配置才能使用。此外,作为一个 Aurelia 插件,它需要在应用启动时加载。

我们可以在主configure函数中完成所有这些。然而,这种情况对于 Aurelia 特性来说确实是一个很好的候选者。如果您还记得,功能与插件类似,只是它们是在应用本身内部定义的。通过引入validation功能,我们可以隔离配置验证,这将为我们提供一个中心位置,在这里我们可以放置额外的服务和自定义验证规则。

让我们从创建validation功能开始:

src/validation/index.js

export function configure(config) { 
  config 
    .plugin('aurelia-validation'); 
} 

我们新功能的configure功能只是加载aurelia-validation插件。

接下来,我们需要在主configure函数中加载此功能:

src/main.js 
//Omitted snippet... 
export function configure(aurelia) { 
  aurelia.use 
    .standardConfiguration() 
    .feature('validation') 
    .feature('resources'); 
  //Omitted snippet... 
} 

在这里,我们只需对引导 fluentapi 的feature方法进行一个附加调用,以加载我们的validation特性。

正在验证联系方式

现在所有内容都已正确配置,让我们将验证添加到contact-edition表单中。

设置模板

为了告诉验证机制需要验证什么,所有用于检索我们想要验证的用户输入的双向绑定都必须使用validate绑定行为进行修饰,这也是由aurelia-validation提供的:

src/contact-edition.html

 <template> 
  <!-- Omitted snippet... -->   
  <input type="text" class="form-control"  
         value.bind="contact.firstName & validate"> 
  <!-- Omitted snippet... --> 
  <input type="text" class="form-control"  
         value.bind="contact.birthday & validate"> 
  <!-- Omitted snippet... --> 
  <textarea class="form-control"  
            value.bind="contact.note & validate"></textarea> 
  <!-- Omitted snippet... --> 
  <select value.bind="phoneNumber.type & validate" class="form-control"> 
  <!-- Omitted snippet... --> 
  <input type="tel" class="form-control" placeholder="Phone number"  
         value.bind="phoneNumber.number & validate"> 
  <!-- Omitted snippet... --> 
</template> 

这里,我们将validate绑定行为添加到每个双向绑定中。这个片段并没有描述contact-edition表单的所有绑定;我将把它作为练习留给读者,让读者在模板中所有inputtextareaselect元素的value属性上添加validate绑定。本章的示例应用来自该书的资产,可作为参考。

validate绑定行为有两个任务。它首先将绑定指令注册到ValidationController,该指令协调给定组件的验证,因此验证机制知道该指令绑定的属性,并可以在需要时对其进行验证。其次,它可以钩住绑定指令,因此当绑定指令所针对的元素的值发生变化时,可以当场验证绑定到元素的属性。

使用 ValidationController

ValidationController在验证过程中担任指挥。它跟踪需要验证的一组绑定,公开手动触发验证的方法,并记录当前验证错误。

为了利用ValidationController,我们必须首先在我们的组件中注入一个实例:

src/contact-edition.js

import {inject, NewInstance} from 'aurelia-framework'; 
import {ValidationController} from 'aurelia-validation'; @inject(ContactGateway, NewInstance.of(ValidationController), Router) 
export class ContactEdition { 

  constructor(contactGateway, validationController, router) { 
    this.contactGateway = contactGateway; 
    this.validationController = validationController; 
    this.router = router; 
  } 
  //Omitted snippet... 
} 

在这里,我们将一个全新的ValidationController实例注入到我们的视图模型中。NewInstance解析器的使用很重要,因为默认情况下,DI 容器将所有服务视为应用单例,并且我们确实希望每个组件都有一个不同的实例,因此在验证时将它们单独考虑。

接下来,我们只需确保表单在保存任何联系人之前有效:

src/contact-edition.js

//Omitted snippet... 
export class ContactEdition { 
  //Omitted snippet... 
  save() { 
    this.validationController.validate().then(errors => { 
 if (errors.length > 0) { 
 return; 
 } 
      //Omitted call to create or update... 
    } 
  } 
} 

在这里,我们封装了调用网关的createupdate方法的代码,以便在验证后执行(完成并且只有在没有错误的情况下才执行)。

validate方法返回一个Promise,该值由包含验证错误的数组解析。这意味着验证规则可以是异步的。例如,自定义规则可以对后端执行 HTTP 调用,以检查数据的唯一性或执行一些进一步的数据验证,并且当 HTTP 调用完成时,validate方法产生的Promise将被解析。

如果异步规则的Promise被拒绝,如果 HTTP 调用失败,例如validate返回的Promise也会被拒绝,所以在使用此类异步远程验证规则时,请确保在这个级别处理拒绝,以便用户知道发生了什么。

新增验证规则

此时,验证已准备就绪,但不会执行任何操作,因为我们尚未在模型上定义任何验证规则。让我们从Contact类开始:

src/models.js

import {ValidationRules} from 'aurelia-validation'; 
// Omitted snippet... 

export class Contact { 
  // Omitted snippet... 

  constructor() { 
 ValidationRules 
 .ensure('firstName').maxLength(100) 
 .ensure('lastName').maxLength(100) 
 .ensure('company').maxLength(100) 
 .ensure('birthday') 
 .satisfies((value, obj) => value === null || value === undefined 
 || value === '' || !isNaN(Date.parse(value))) 
 .withMessage('${$displayName} must be a valid date.') 
 .ensure('note').maxLength(2000) 
 .on(this); 
 } 

  //Omitted snippet... 
} 

在这里,我们使用aurelia-validation的 fluent API 为Contact的一些属性添加验证规则:firstNamelastNamecompany属性不能超过 100 个字符,note属性不能超过 2000 个字符。

此外,我们使用satisfies方法为birthday属性定义一个内联的自定义规则。此规则确保birthday只有在为空值或可解析为有效Date对象的字符串时才有效。我们还使用withMessage方法指定违反自定义规则时应显示的错误消息模板。

消息模板使用与 Aurelia 模板引擎相同的字符串插值语法,并且可以使用名为$displayName的上下文变量,该变量包含正在验证的属性的显示名称。

自定义验证规则应始终接受空值。这是为了保持关注点的分离;required规则已经负责拒绝空值,因此您的自定义规则应该只关注它自己的特定验证逻辑。通过这种方式,开发人员可以使用您的自定义规则(带或不带required),具体取决于他们想做什么。

最后,on方法将刚刚构建的规则集附加到Contact实例的元数据。这样,当验证Contact对象的属性时,验证过程可以检索应该应用的验证规则。

我们还需要向Contact中表示列表项的所有类添加验证规则:

src/models.js

//Omitted snippet... 

export class PhoneNumber { 
  //Omitted snippet... 

  constructor() { 
 ValidationRules 
 .ensure('type').required().maxLength(25) 
 .ensure('number').required().maxLength(25) 
 .on(this); 
 } 

  //Omitted snippet... 
} 

export class EmailAddress { 
  //Omitted snippet...   

  constructor() { 
 ValidationRules 
 .ensure('type').required().maxLength(25) 
 .ensure('address').required().maxLength(250).email() 
 .on(this); 
 } 

  //Omitted snippet...   
} 

export class Address { 
  //Omitted snippet... 

  constructor() { 
 ValidationRules 
 .ensure('type').required().maxLength(25) 
 .ensure('number').required()maxLength(100) 
 .ensure('street').required().maxLength(100) 
 .ensure('postalCode').required().maxLength(25) 
 .ensure('city').required().maxLength(100) 
 .ensure('state').maxLength(100) 
 .ensure('country').required().maxLength(100) 
 .on(this); 
 } 

  //Omitted snippet... 
} 

export class SocialProfile { 
  //Omitted snippet...   

  constructor() { 
 ValidationRules 
 .ensure('type').required().maxLength(25) 
 .ensure('username').required().maxLength(100) 
 .on(this); 
 } 

  //Omitted snippet...   
} 

在这里,我们创建每个属性required,并为每个属性指定最大长度。此外,我们确保EmailAddress类的address属性是有效的电子邮件。

呈现验证错误

此时,如果表单无效,save方法不会向后端发送任何 HTTP 请求,这是正确的行为。但是,它仍然不会显示任何错误消息。让我们看看如何向用户显示验证错误。

错误属性

控制器具有包含当前验证错误的errors属性。例如,此属性可用于呈现验证摘要:

src/contact-edition.html

<template>   
  <!-- Omitted snippet... --> 
  <form class="form-horizontal" submit.delegate="save()"> 
    <ul class="col-sm-9 col-sm-offset-3 list-group text-danger" 
        if.bind="validationController.errors"> 
      <li repeat.for="error of validationController.errors"  
          class="list-group-item"> 
        ${error.message} 
      </li> 
    </ul> 
    <!-- Omitted snippet... --> 
  </form> 
</template> 

在本例中,我们添加了一个无序列表,只有当验证控制器出现错误时才会呈现该列表。在这个列表中,我们为每个error重复一个列表项。在每个列表项中,我们呈现错误的message

验证错误属性

也可以使用validation-errors自定义属性检索,不是所有验证错误,而是仅检索范围较窄的验证错误。

当添加到给定元素时,该属性将收集其宿主元素下所有已验证绑定指令的验证错误,并使用双向绑定将这些错误分配给绑定到的属性。

例如,让我们从前面的示例中删除验证摘要,并使用validation-errors属性为表单中的特定字段呈现错误:

src/contact-edition.html

<template> 
  <!-- Omitted snippet... --> 
  <div validation-errors.bind="birthdayErrors"  
       class="form-group ${birthdayErrors.length ? 'has-error' : ''}"> 
    <label class="col-sm-3 control-label">Birthday</label> 
    <div class="col-sm-9"> 
      <input type="text" class="form-control"  
             value.bind="contact.birthday & validate"> 
      <span class="help-block" repeat.for="errorInfo of birthdayErrors"> 
 ${errorInfo.error.message} 
 <span> 
    </div> 
  </div> 
  <!-- Omitted snippet... --> 
</template> 

在这里,我们将validation-errors属性添加到包含birthday属性编辑器的form-group div中,我们将其绑定到一个新的birthdayErrors属性。如果birthday有任何错误,我们也会将has-errorCSS 类添加到form-group div中。最后,我们添加了一个help-block span,它针对birthdayErrors数组中的每个错误重复,并显示错误的message

创建自定义 ValidationRenderer

validation-errors属性允许我们显示模板中特定区域范围内的错误。然而,如果我们必须为表单中的每个属性添加此代码,那么它将很快变得乏味和无效。谢天谢地,aurelia-validation提供了一种在专用服务(名为验证呈现器)中提取该逻辑的机制。

验证呈现程序是实现render方法的类。此方法接收验证呈现指令对象作为其第一个参数。此指令对象包含有关应显示哪些错误以及应删除哪些错误的信息。它基本上是上一个验证状态和当前验证状态之间的增量,因此渲染器知道必须对 DOM 中显示的错误消息应用哪些更改。

在撰写本文时,Aurelia 中没有可用的验证呈现器。很可能一些社区插件不久将为主要的 CSS 框架提供渲染器。同时,让我们自己来实现这一点:

src/validation/bootstrap-form-validation-renderer.js

export class BootstrapFormValidationRenderer { 

  render(instruction) { 
    for (let { error, elements } of instruction.unrender) { 
      for (let element of elements) { 
        this.remove(element, error); 
      } 
    } 

    for (let { error, elements } of instruction.render) { 
      for (let element of elements) { 
        this.add(element, error); 
      } 
    } 
  } 
} 

这里,我们导出一个名为BootstrapFormValidationRenderer的类,它包含一个render方法。这个方法简单地将instruction的错误迭代到unrender,然后对每个错误的elements进行迭代,并调用一个remove方法,稍后我们将编写这个方法。接下来,它在instruction的错误上循环到render,然后在每个错误的elements上循环,并调用add方法。

接下来,我们需要通过将add方法写入我们的 validation renderer 类来告诉我们的类如何显示验证错误:

add(element, error) { 
  const formGroup = element.closest('.form-group'); 
  if (!formGroup) { 
    return; 
  } 

  formGroup.classList.add('has-error'); 

  const message = document.createElement('span'); 
  message.className = 'help-block validation-message'; 
  message.textContent = error.message; 
  message.id = `bs-validation-message-${error.id}`; 
  element.parentNode.insertBefore(message, element.nextSibling); 
} 

在这里,我们使用form-groupCSS 类检索元素,该元素与承载触发错误的绑定指令的元素最接近,我们将has-errorCSS 类添加到该元素中。接下来,我们创建一个help-block span,其中将包含错误的message。我们还使用错误的id设置了它的id属性,因此当需要删除它时,我们可以很容易地找到它。最后,我们在 DOM 中插入这个消息元素,就在触发错误的元素之后。

为了完成渲染器,让我们编写一个方法来删除以前渲染的验证错误:

remove(element, error) { 
  const formGroup = element.closest('.form-group'); 
  if (!formGroup) { 
    return; 
  } 

  const message = formGroup.querySelector( 
    `#bs-validation-message-${error.id}`); 
  if (message) { 
    element.parentNode.removeChild(message); 
    if (formGroup.querySelectorAll('.help-block.validation-message').length  
        === 0) {     
      formGroup.classList.remove('has-error'); 
    } 
  } 
} 

在这里,我们首先使用form-groupCSS 类检索元素,该类与承载触发错误的绑定指令的元素最接近。然后,我们使用错误的id检索消息元素,并将其从 DOM 中删除。最后,如果form-group不包含任何错误消息,我们将从中删除has-errorCSS 类。

我们的验证呈现程序现在必须通过依赖项注入容器提供给应用。从逻辑上讲,我们将在validation功能的configure功能中实现这一点:

src/validation/index.js

//Omitted snippet... 
import {BootstrapFormValidationRenderer} 
 from './bootstrap-form-validation-renderer'; 

export function configure(config) { 
  config.plugin('aurelia-validation'); 
  config.container.registerHandler( 
 'bootstrap-form', 
 container => container.get(BootstrapFormValidationRenderer)); 
} 

在这里,我们以名称bootstrap-form注册验证呈现程序。然后,我们可以在contact-edition表单中使用此名称来告诉验证控制器,应该使用此呈现程序来显示form的验证错误:

src/contact-edition.html

<template> 
  <!-- Omitted snippet... --> 
  <form class="form-horizontal" submit.delegate="save()" 
        validation-renderer="bootstrap-form"> 
    <!-- Omitted snippet... --> 
  </form> 
  <!-- Omitted snippet... --> 
</template> 

validation-renderer属性将使用提供的名称作为其值解析BootstrapFormValidationRenderer的实例,并将其注册到当前验证控制器。然后,控制器将在每次验证状态发生更改时通知渲染器,以便可以渲染任何新错误并删除任何已解决的错误。

使用字符串键注册渲染器的事实允许以不同的名称注册多个验证渲染器,因此可以以不同的形式使用不同的渲染器。

更改验证触发器

默认情况下,当属性绑定到的元素失去焦点时,将执行属性验证。但是,可以通过设置控制器的validateTrigger属性来更改此行为:

src/contact-edition.js

import {ValidationController, validateTrigger} from 'aurelia-validation'; 
// Omitted snippet... 
export class ContactEdition { 
  constructor(contactGateway, validationController, router) { 
    validationController.validateTrigger = validateTrigger.change; 
    // Omitted snippet... 
  } 
} 

在这里,我们首先导入validateTrigger枚举,并告诉ValidationController它应该在绑定到属性的元素的值每次更改时重新验证属性。

除了change之外,validateTrigger枚举还有三个值:

  • blur: The property is validated when the element hosting the binding instruction loses focus. This is the default value.
  • changeOrBlur: The property is validated when the binding instruction changes or when the hosting element loses focus. It basically combines the behavior of both change and blur.
  • manual: Automatic validation is completely disabled. In this case, only a call to the controller's validate method, like we do in the save method, can trigger validation, and it is performed on all registered bindings at once.

当然,即使validateTriggerblurchangeblurOrChange,对validate方法的显式调用也将始终执行验证。

创建自定义验证规则

aurelia-validation库使添加自定义验证规则变得容易。为了说明这一点,我们首先将应用于Contactbirthday属性的规则移动到可重用的date验证规则中。然后,我们还将向联系人照片上载组件添加验证,这将需要一些自定义规则来验证文件。

确认日期

让我们首先创建一个文件,该文件将声明并注册我们的各种自定义规则:

src/validation/rules.js

import {ValidationRules} from 'aurelia-validation'; 

ValidationRules.customRule( 
  'date',  
  (value, obj) => value === null || value === undefined || value === ''  
                  || !isNaN(Date.parse(value)),  
  '${$displayName} must be a valid date.' 
); 

此文件不导出任何内容。它只导入ValidationRules类,并使用其customRule静态方法注册一个新的date规则,该规则重用我们之前在Contact类中定义的条件和消息。

接下来,我们需要在某个地方导入此文件,以便注册规则并使其可供应用使用。最好的方法是使用validation功能的configure功能:

src/validation/index.js

import './rules'; 
//Omitted snippet... 

通过导入rules文件,注册了date自定义规则,因此只要 Aurelia 导入了validation功能,它就可以使用。

最后,我们现在可以更改Contactbirthday属性的ValidationRules,因此它使用此规则:

src/models.js

//Omitted snippet... 
export class Contact { 
  //Omitted snippet... 

  constructor() { 
    ValidationRules 
      .ensure('firstName').maxLength(100) 
      .ensure('lastName').maxLength(100) 
      .ensure('company').maxLength(100) 
 .ensure('birthday').satisfiesRule('date') 
      .ensure('note').maxLength(2000) 
      .on(this); 
  } 

  //Omitted snippet... 
} 
//Omitted snippet... 

在这里,我们只需删除对birthday属性的satisfies调用,并将其替换为对satisfiesRule的调用,该调用期望规则的名称作为其第一个参数。

正在验证是否选择了文件

此时,如果未选择任何文件,则当用户单击保存按钮时,联系人照片上载组件不会执行任何操作。我们可以在验证方面做的第一件事是确保选择了一个文件。因此,我们将创建一个名为notEmpty的新规则,该规则将确保验证值的length属性大于零:

src/validation/rules.js

//Omitted snippet... 
ValidationRules.customRule( 
  'notEmpty', 
  (value, obj) => value && value.length && value.length > 0, 
  '${$displayName} must contain at least one item.' 
); 

这里,我们使用ValidationRules类的customRule静态方法来全局注册我们的验证规则。此方法需要以下参数:

  • The name of the rule. It must be unique.
  • The condition function, which is passed the value and the parent object, if any. It is expected to return true if the rule is fulfilled or false if the rule is violated. It can also return a Promise resolving to a boolean.
  • The message error template.

此规则将能够处理具有length属性的任何值。例如,它可以用于阵列或FileList实例。

正在验证文件的大小

接下来,我们将创建一个验证规则,以确保FileList实例中的所有文件的重量小于最大大小:

src/validation/rules.js

//Omitted snippet... 
ValidationRules.customRule( 
  'maxFileSize', 
  (value, obj, maximum) => !(value instanceof FileList) 
    || value.length === 0 
    || Array.from(value).every(file => file.size <= maximum), 
  '${$displayName} must be smaller than ${$config.maximum} bytes.', 
  maximum => ({ maximum }) 
); 

在这里,我们首先定义一个新的maxFileSize验证规则,确保FileList中每个文件的size不超过给定的maximum。该规则仅在值为FileList实例且FileList不为空时适用。

此规则需要一个maximum参数。当使用这样的规则时,传递给satisfiesRulefluent 方法的任何参数都会传递给基础条件函数,以便它可以使用它来计算条件。但是,为了可用于消息模板,必须在单个对象内聚合规则参数。因此,customRule可以传递第四个参数,该参数应该是将规则参数聚合到单个对象中的函数。然后,该对象作为$config提供给消息模板。

这就是我们在maxFileSize规则中看到的;它需要使用一个参数调用,这里名为maximum,它是以字节为单位的最大文件大小。将规则添加到属性时,此参数应传递给satisfiesRule方法:

ValidationRules.ensure('photo').satisfiesRule('maxFileSize', 1024); 

然后将此参数传递给条件函数,以便验证FileList实例中所有文件的大小。它还被传递给聚合函数,聚合函数返回一个包含maximum作为属性的对象。然后该对象作为$config可用于消息模板,因此该模板可以在错误消息中显示maximum

在这里,我们的自定义规则只有一个参数,但一个规则可以有您需要的任意多个参数。它们都将按照传递给satisfiesRule的相同顺序传递给条件函数和聚合函数。

正在验证文件扩展名

最后,让我们创建一个规则,以确保FileList实例中的所有文件都有一个位于特定值集中的扩展名:

src/validation/rules.js

//Omitted snippet... 
function hasOneOfExtensions(file, extensions) { 
  const fileName = file.name.toLowerCase(); 
  return extensions.some(ext => fileName.endsWith(ext)); 
} 

function allHaveOneOfExtensions(files, extensions) { 
  extensions = extensions.map(ext => ext.toLowerCase()); 
  return Array.from(files) 
    .every(file => hasOneOfExtensions(file, extensions)); 
} 

ValidationRules.customRule( 
  'fileExtension', 
  (value, obj, extensions) => !(value instanceof FileList) 
    || value.length === 0 
    || allHaveOneOfExtensions(value, extensions), 
  '${$displayName} must have one of the following extensions: '  
    + '${$config.extensions.join(', ')}.', 
  extensions => ({ extensions }) 
); 

这个名为fileExtension的规则需要一个文件扩展名数组作为参数,并确保FileList中所有文件的名称都以其中一个扩展名结尾。与maxFileSize类似,仅当验证值为非空的FileList实例时才适用。

正在验证联系人照片选择器

现在我们已经定义了验证 contact photo 组件所需的所有规则,让我们设置视图模型,就像我们对contact-edition组件所做的那样:

  1. Inject a NewInstance of ValidationController in the ContactPhoto view-model
  2. Explicitly call the validate method in save, and omit calling updatePhoto if there are any validation errors
  3. Add the validation-renderer="bootstrap-form" attribute to the form element in the contact-photo.html template
  4. Add the validate binding behavior to the binding of the files property on the file input

这些任务与我们已经为contact-edition组件完成的任务相同,我将把它们作为练习留给读者。

接下来,我们需要将验证规则添加到视图模型的photo属性中:

src/contact-photo.js

import {ValidationController, ValidationRules} from 'aurelia-validation'; 
//Omitted snippet... 
export class ContactPhoto { 
  //Omitted snippet... 

  constructor(contactGateway, router, validationController) { 
    //Omitted snippet... 
    ValidationRules 
      .ensure('photo') 
        .satisfiesRule('notEmpty') 
          .withMessage('${$displayName} must contain 1 file.') 
        .satisfiesRule('maxFileSize', 1024 * 1024 * 2) 
        .satisfiesRule('fileExtension', ['.jpg', '.png']) 
      .on(this); 
  } 

  //Omitted snippet... 
} 

在这里,我们告诉验证控制器,photo必须包含至少一个文件,该文件必须是 JPEG 或 PNG,并且必须最大为 2MB。我们还使用withMessage方法自定义未选择任何文件时显示的消息。

If you test this, it should work properly. However, the fact that the validation is triggered when the file input loses focus makes usability a bit strange. In order to have the form validated right when the user closes the browser's file selection dialog, which will display potential error messages right away, let's change the validation controller's validateTrigger to change:

src/contact-photo.js

import {ValidationController, ValidationRules, validateTrigger}  
  from 'aurelia-validation'; 
//Omitted snippet... 
export class ContactPhoto { 
  //Omitted snippet... 

  constructor(contactGateway, router, validationController) { 
    validationController.validateTrigger = validateTrigger.change; 
    //Omitted snippet... 
  } 

  //Omitted snippet... 
} 

If you test after doing this change, you should find that the usability is much better, as the file is validated as soon as the user closes the file selection dialog.

Editing complex structures

In the previous sections, we created a form to edit, among others, lists of items, using a strategy that is referred to as inline edition. The form includes input elements for every list item. This strategy reduces to a minimum the number of clicks the user has to do to edit or add new list items, because he or she can already edit all fields of all list items directly in the form.

However, when a form needs to manage lists of more complex items, one solution is to display only the most relevant information in the list as read-only, and to use a modal dialog to create or edit items. A dialog leaves much more room to display a complex form for a single item.

The aurelia-dialog plugin exposes a dialog functionality, which we can leverage to create modal editors. To illustrate this, we will fork our contact management application and change the contact-edition component so it uses dialog edition instead of inline edition for list items.

The following code snippets are excerpts of chapter-4/samples/list-edition-models.

Installing the dialog plugin

To install the aurelia-dialog plugin, simply fire up a console in the project directory and run the following command:

> npm install aurelia-dialog --save 

Once installation is completed, we also need to add the plugin to the vendor bundle configuration. To do this, open aurelia_project/aurelia.json and, under build, then bundles, in the dependencies array of the bundle named vendor-bundle.js, add the following entry:

{ 
  "name": "aurelia-dialog", 
  "path": "../node_modules/aurelia-dialog/dist/amd", 
  "main": "aurelia-dialog" 
}, 

Lastly, we need to load the plugin in our main configure function:

src/main.js

//Omitted snippet... 
export function configure(aurelia) { 
  aurelia.use 
    .standardConfiguration() 
    .plugin('aurelia-dialog') 
    .feature('validation') 
    .feature('resources'); 
  //Omitted snippet... 
} 

At this point, the services and components exposed by aurelia-dialog are ready to be used in our application.

Creating the edition dialogs

The dialog plugin uses composition to display a component as a dialog. This means that the next step is to create the components that will be used to edit a new or an existing item.

Since the behavior behind dialog edition will be the same whatever the type of the item being edited, we will create a single view-model, which we will reuse for phone numbers, email addresses, addresses, and social profiles:

src/dialogs/edition-dialog.js

import {inject, NewInstance} from 'aurelia-framework'; 
import {DialogController} from 'aurelia-dialog'; 
import {ValidationController} from 'aurelia-validation'; 

@inject(DialogController, NewInstance.of(ValidationController)) 
export class EditionDialog { 

  constructor(dialogController, validationController) { 
    this.dialogController = dialogController; 
    this.validationController = validationController; 
  } 

  activate(model) { 
    this.model = model; 
  } 

  ok() { 
    this.validationController.validate().then(errors => { 
      if (errors.length === 0) { 
        this.dialogController.ok(this.model) 
      } 
    }); 
  } 

  cancel() { 
    this.dialogController.cancel(); 
  } 
} 

Here, we create a component in which we inject a DialogController and a NewInstance of the ValidationController class. Next, we define an activate method receiving the model, which will be the item to edit - the phone number, email address, address, or social profile. We also define an ok method which validates the form and, if there are no errors, calls the ok method of DialogController with the updated model as the dialog's output. Finally, we define a cancel method, which simply delegates to the cancel method of DialogController.

A DialogController, when injected in a component displayed as a dialog, is used to control the dialog into which the component is displayed. Its ok and cancel methods can be used to close the dialog and to return a response to the caller. This response can then be used by the caller to determine if the dialog was canceled or not and to retrieve its output, if any.

Even though we will reuse the same view-model class for all item types, the templates must be different for each item type. Let's start with the dialog edition for phone numbers:

src/dialogs/phone-number-dialog.html

<template> 
  <ai-dialog> 
    <form class="form-horizontal" validation-renderer="bootstrap-form"  
          submit.delegate="ok()"> 
      <ai-dialog-body> 
        <h2>Phone number</h2> 
        <div class="form-group"> 
          <div class="col-sm-2"> 
            <label for="type">Type</label> 
          </div> 
          <div class="col-sm-10"> 
            <select id="type" value.bind="model.type & validate"  
                    attach-focus="true" class="form-control"> 
              <option value="Home">Home</option> 
              <option value="Office">Office</option> 
              <option value="Mobile">Mobile</option> 
              <option value="Other">Other</option> 
            </select> 
          </div> 
        </div> 
        <div class="form-group"> 
          <div class="col-sm-2"> 
            <label for="number">Number</label> 
          </div> 
          <div class="col-sm-10"> 
            <input id="number" type="tel" class="form-control"  
                   placeholder="Phone number"  
                   value.bind="model.number & validate"> 
          </div> 
        </div> 
      </ai-dialog-body>
<ai-dialog-footer> 
        <button type="submit" class="btn btn-primary">Ok</button> 
        <button class="btn btn-danger"  
                click.trigger="cancel()">Cancel</button> 
      </ai-dialog-footer> 
    </form> 
  </ai-dialog> 
</template> 

Here, the notable parts are the ai-dialog, ai-dialog-body, and ai-dialog-footer elements, which are containers for an Aurelia dialog. Additionally, the attach-focus="true" attribute on the select element makes sure that this element is given the focus when the dialog is displayed. Lastly, the submit event of form delegates to the ok method, while a click on the cancel button delegates to the cancel method.

The rest of the template should be familiar. The user input elements are bound to the properties of model, and those bindings are decorated with the validate binding behavior so the properties are properly validated.

We also need to create the templates for the other item types: src/dialogs/email-address-dialog.html, src/dialogs/address-dialog.html, and src/dialogs/social-profile-dialog.html. At this point, those templates should be easy to create. I'll leave it as an exercise to the reader to write them; the list-edition-models sample can be used as a reference.

Using edition dialogs

The last step to leverage our new view-model and templates is to change the behavior of the contact-edition component:

src/contact-edition.js

import {DialogService} from 'aurelia-dialog'; 
import {Contact, PhoneNumber, EmailAddress, Address, SocialProfile}  
  from './models'; 
//Omitted snippet... 
@inject(ContactGateway, NewInstance.of(ValidationController), Router,  
        DialogService) 
export class ContactEdition { 
  constructor(contactGateway, validationController, router, dialogService) { 
    this.contactGateway = contactGateway; 
    this.validationController = validationController; 
    this.router = router; 
    this.dialogService = dialogService; 
  } 
   //Omitted snippet... 
  _openEditDialog(view, model) { 
    return new Promise((resolve, reject) => { 
      this.dialogService.open({  
        viewModel: 'dialogs/edition-dialog', 
        view: `dialogs/${view}-dialog.html`,  
        model: model 
      }).then(response => { 
        if (response.wasCancelled) { 
          reject(); 
        } else { 
          resolve(response.output); 
        } 
      }); 
    }); 
  } 

  editPhoneNumber(phoneNumber) { 
    this._openEditDialog('phone-number',  
                         PhoneNumber.fromObject(phoneNumber)) 
      .then(result => { Object.assign(phoneNumber, result); }); 
  } 

  addPhoneNumber() { 
    this._openEditDialog('phone-number', new PhoneNumber()) 
      .then(result => { this.contact.phoneNumbers.push(result); }); 
  } 

  //Omitted snippet... 
} 

Here, we add a new dependency to our ContactEdition view-model by injecting a DialogService into its constructor. We next define a _openEditDialog method, which defines a common behavior to open an edition dialog.

This method calls the open method of DialogService to open a dialog, using the edition-dialog view-model and a given item type's template, composed as a single component. A given model is also passed to it, which will be injected in the activate method of edition-dialog. This should be familiar if you read the section about composition in Chapter 3, Displaying Data.

Additionally, the method returns a Promise, which will be resolved when the user clicks Ok, but rejected when he clicks Cancel. This way, when using this method, the resulting Promise will be resolve only when the user confirms its modifications by clicking Ok and will be rejected otherwise.

The editPhoneNumber method uses the _openEditDialog method to display the phone number edition dialog. A copy of the phoneNumber to edit is passed as the model because, if we pass the original phoneNumber object, it will be modified even if the user cancels its modifications. When the Promise resolves, which happens when the user confirms its modifications, the updated model properties are assigned back to the original phoneNumber.

Similarly, the addPhoneNumber method uses the _openEditDialog method, but passes a new PhoneNumber instance as the model. Additionally, when the Promise resolves, the new phone number is added to the phoneNumbers array of contact.

Lastly, the template must be changed, so the list of phone numbers is displayed as read-only, and a new Edit button must be added for each phone number:

src/contact-edition.html

<template> 
  <!-- Omitted snippet... --> 
  <hr> 
  <div class="form-group" repeat.for="phoneNumber of contact.phoneNumbers"> 
    <div class="col-sm-2 col-sm-offset-1">${phoneNumber.type}</div> 
    <div class="col-sm-7">${phoneNumber.number}</div> 
    <div class="col-sm-1"> 
      <button type="button" class="btn btn-danger"  
              click.delegate="editPhoneNumber(phoneNumber)"> 
        <i class="fa fa-pencil"></i> Edit 
      </button> 
    </div> 
    <div class="col-sm-1"> 
      <button type="button" class="btn btn-danger"  
              click.delegate="contact.phoneNumbers.splice($index, 1)"> 
        <i class="fa fa-times"></i>  
      </button> 
    </div> 
  </div> 
  <div class="form-group"> 
    <div class="col-sm-9 col-sm-offset-3"> 
      <button type="button" class="btn btn-primary"  
              click.delegate="addPhoneNumber()"> 
        <i class="fa fa-plus-square-o"></i> Add a phone number 
      </button> 
    </div> 
  </div> 
  <!-- Omitted snippet... --> 
</template> 

Here, we remove the select and input elements and replace them with string interpolation instructions to display the type and number properties of phoneNumber. We also add an Edit button, which, when clicked, calls the new editPhoneNumber method. Lastly, we change the Add button so it calls the new addPhoneNumber method.

Of course, the same changes must be applied to both the view-model and the template of the contact-edition component for the other item types. However, changing the inline edition strategy for the email addresses, the addresses, and the social profiles for dialog edition should be straightforward to you now; I'll leave this as an exercise to the reader.

Summary

Creating forms is simple with Aurelia, it is mostly a matter of leveraging two-way binding. Validating forms is also easy, thanks to the validation plugin. Additionally, the abstraction layer in the validation plugin allows us to use the validation library we want, even though the default implementation provided by the plugin is already pretty powerful.

The power of Aurelia will really start to become clear in the next chapter. By leveraging what we saw up to now, and adding custom attributes, custom elements, and content projection to the mix, we will be able to create extremely powerful, reusable, and extensible components, composing them into modular and testable applications. Of course, while covering those topics, we will heavily refactor our contact management application to extract components and reusable behaviors from our existing code base, while we add features that would be undoable without custom elements and attributes.