四、表格,以及如何验证表格
在本章中,我们将了解用户输入元素(如input
、select
和textarea
)的数据绑定工作原理。我们还将了解 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
属性将被赋给绑定到select
的value
的表达式。在本例中,selectedCountry
属性将被指定选定的country
值。
option
元素的value
属性只需要字符串值。在前面的示例中,countries
属性是一个字符串数组,因此每个option
的value
都绑定到一个字符串。要呈现绑定到任何其他类型的值(例如对象)的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>
在本例中,所选option
的value
绑定到对应项目的isoCode
属性,该属性为字符串,因此所选项目的该属性将被分配给selectedCultureIsoCode
。
当然,在渲染过程中,将计算绑定到select
属性value
的表达式的值,如果任何option
具有匹配的value
或model
属性,则该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
属性时,可能会发生分配给select
的value
的对象与分配给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
属性的任何更改也将应用于input
的value
。
对于大多数其他类型的input
:color
、date
、email
、number
、password
、tel
或url
的用法相同,仅举几个例子。但也有一些特殊情况,如下所述。
文件选择器
input
元素的type
属性为file
时,将其files
属性作为属性公开。默认情况下,它使用双向绑定:
<template>
<input type="file" accepts="image/*" files.bind="images">
</template>
在本例中,input``files
属性绑定到视图模型的images
属性。当用户选择一个文件时,images
被分配一个FileList
对象,其中包含作为单个File
对象的所选文件。如果input
具有multiple
属性,则用户可以选择多个文件,生成的FileList
对象包含用户选择的数量相同的File
对象。
FileList
和File
类是 HTML5 文件 API 的一部分,可以与 Fetch API 一起使用,将用户选择的文件发送到服务器。在本章后面的联系人应用中构建照片编辑组件时,我们将看到一个例子。
Mozilla 开发者网络有大量关于文件 API 的文档。有关FileList
类的详细信息,请访问https://developer.mozilla.org/en-US/docs/Web/API/FileList 。
单选按钮
与select
元素的option
类似,单选按钮可以使用value
或model
属性来指定选择按钮时使用的值。value
属性只需要字符串值,因此model
属性必须用于任何其他类型的值。
此外,单选按钮可以将其checked
属性(默认情况下是双向的)绑定到一个表达式,该表达式在选中时将被指定为按钮的value
或model
。
<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
属性,该属性将被分配null
、true
或false
,具体取决于选择的按钮。
当然,在渲染过程中,如果绑定到组中按钮的checked
属性的表达式具有与按钮的value
或model
属性之一匹配的值,则该按钮将被渲染为选中。
复选框
复选框列表的典型用法类似于具有multiple
属性的select
元素。每个input
元素都有value
或model
属性。另外,一个checked
属性需要绑定到一个数组,所有选中的input
的value
或model
都将被添加到该数组中:
<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
属性的所选复选框culture
的isoCode
属性将添加到字符串的selectedCulturesIsoCodes
数组中。
当然,在渲染过程中,如果绑定到checked
属性的数组包含绑定到value
或model
属性的值,则此复选框将被渲染为选中。
或者,复选框可以绑定到不同的布尔表达式,而不是单个数组。这可以通过省略任何value
或model
属性来实现:
<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
都绑定到一个不同的属性,该属性将被分配true
或false
,具体取决于复选框是否选中。
文本区
绑定到textarea
元素与绑定到text``input
元素相同:
<template>
<textarea value.bind="text"></textarea>
</template>
这里,text
属性的初始值将显示在textarea
中,并且由于textarea
的value
属性的绑定默认为双向,因此用户对textarea
内容所做的所有修改都将反映在text
属性上。
禁用一个元素
禁用input
、select
、textarea
或button
元素只是绑定到其disabled
属性的问题:
<template>
<input type="text" disabled.bind="isSending">
<button disabled.bind="isSending">Send</button>
</template>
这里,当isSending
为true
时,input
和button
元素都将被禁用。
使元素只读
类似地,使input
或textarea
元素只读只是绑定到其readonly
属性的问题:
<template>
<input type="text" readonly.bind="!canEdit">
</template>
这里,canEdit
为false
时,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
路由,则无法访问该路径,而将使用等于new
的id
参数来访问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-photo
和contact-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>
在这里,我们首先重构显示fullName
和company
的模板(如果联系人是个人),方法是添加一个封闭的div
并将col-sm-10
CSS 类从标题移动到这个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
。
编辑标量属性
接下来,我们将添加包含输入元素的块,以编辑联系人的firstName
、lastName
、company
、birthday
和note
,在前面代码段中定义的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
绑定到phoneNumber
的type
属性,以及一个tel input
,其value
绑定到phoneNumber
的number
属性。此外,我们还定义了一个button
,它的click
事件使用当前的$index
将电话号码从contact
的phoneNumbers
数组中拼接出来,正如您在上一章中所记得的,当前的$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``type
为email
。
正如您所想象的,地址编辑器将稍微大一点:
<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-client
的json
函数。此函数将任何 JS 值作为参数,并返回一个包含序列化为 JSON 的接收参数的Blob
对象。
接下来,我们添加一个create
方法,该方法将contact
作为参数,并调用 HTTP 客户端的fetch
方法,将要调用的相对 URL 传递给它,后面是一个配置对象。此对象包含将分配给基础Request
对象的属性。在这里,我们指定一个method
属性,告诉客户机执行POST
请求,并指示请求的body
将被序列化为 JSON 的contact
。最后,fetch
方法返回一个Promise
,这是我们新的create
方法返回的,因此调用方可以在请求完成时做出反应。
update
方法非常相似。第一个区别是参数:首先需要触点的id
,然后是contact
对象本身。第二,fetch
呼叫略有不同;它使用PUT
方法向不同的 URL 发送请求,但其主体是相同的。
获取Request
的body
应为Blob
、BufferSource
、FormData
、URLSearchParams
或USVString
对象。有关这方面的文档可在 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
路径;否则,当组件编辑现有联系人时,我们首先调用ContactGateway
的update
方法,将联系人的id
和contact
对象传递给它,然后在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
实例,并使用contact
的fullName
初始化文档标题。这与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
方法只是使用DELETE
HTTP 方法对后端的正确路径执行 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
表单的所有绑定;我将把它作为练习留给读者,让读者在模板中所有input
、textarea
和select
元素的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...
}
}
}
在这里,我们封装了调用网关的create
或update
方法的代码,以便在验证后执行(完成并且只有在没有错误的情况下才执行)。
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
的一些属性添加验证规则:firstName
、lastName
和company
属性不能超过 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-error
CSS 类添加到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-group
CSS 类检索元素,该元素与承载触发错误的绑定指令的元素最接近,我们将has-error
CSS 类添加到该元素中。接下来,我们创建一个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-group
CSS 类检索元素,该类与承载触发错误的绑定指令的元素最接近。然后,我们使用错误的id
检索消息元素,并将其从 DOM 中删除。最后,如果form-group
不包含任何错误消息,我们将从中删除has-error
CSS 类。
我们的验证呈现程序现在必须通过依赖项注入容器提供给应用。从逻辑上讲,我们将在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 bothchange
andblur
.manual
: Automatic validation is completely disabled. In this case, only a call to the controller'svalidate
method, like we do in thesave
method, can trigger validation, and it is performed on all registered bindings at once.
当然,即使validateTrigger
是blur
、change
或blurOrChange
,对validate
方法的显式调用也将始终执行验证。
创建自定义验证规则
aurelia-validation
库使添加自定义验证规则变得容易。为了说明这一点,我们首先将应用于Contact
的birthday
属性的规则移动到可重用的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
功能,它就可以使用。
最后,我们现在可以更改Contact
的birthday
属性的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 orfalse
if the rule is violated. It can also return aPromise
resolving to aboolean
. - 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
参数。当使用这样的规则时,传递给satisfiesRule
fluent 方法的任何参数都会传递给基础条件函数,以便它可以使用它来计算条件。但是,为了可用于消息模板,必须在单个对象内聚合规则参数。因此,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
组件所做的那样:
- Inject a
NewInstance
ofValidationController
in theContactPhoto
view-model - Explicitly call the
validate
method insave
, and omit callingupdatePhoto
if there are any validation errors - Add the
validation-renderer="bootstrap-form"
attribute to theform
element in thecontact-photo.html
template - Add the
validate
binding behavior to the binding of thefiles
property on thefile 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.
版权属于:月萌API www.moonapi.com,转载请注明出处