构建复杂组件
当用户搜索租赁时,他可能还想将搜索范围缩小到特定城市。虽然我们的初始租赁列表组件仅显示租赁信息,但此新的过滤器组件还将允许用户以过滤条件的形式提供输入。
首先,让我们生成新的组件list-filter
。我们的需求是希望组件根据用户输入过滤租赁列表。
$ ember g component list-filter
installing component
create app/components/list-filter.js
create app/templates/components/list-filter.hbs
installing component-test
create tests/integration/components/list-filter-test.js
生成了一个组件模板、一个JavaScript文件和一个组件集成测试文件。
为组件提供标记
在app/templates/rentals.hbs
模板文件中,我们将添加对新list-filter
组件的引用。
请注意,在下面的模板中,我们用list-filter
的开始和结束标记“包裹”了之前的租赁列表标记(each
)。这是一个组件的块形式示例,这允许在组件内部渲染handlebars模板。
在下面的代码中,我们将过滤器数据作为变量rentals
传入到内部模板中。 app/templates/rentals.hbs
:
<div class="jumbo">
<div class="right tomster"></div>
<h2>Welcome!</h2>
<p>
We hope you find exactly what you're looking for in a place to stay.
</p>
{ { #link-to 'about' class="button" } }
About Us
{ { /link-to } }
</div>
{ { #list-filter
filter=(action 'filterByCity')
as |rentals| } }
<ul class="results">
{ { #each rentals as |rentalUnit| } }
<li>{ { rental-listing rental=rentalUnit } }</li>
{ { /each } }
</ul>
{ { /list-filter } }
接受组件的输入
我们希望组件简单地提供一个输入域,和显示过滤结果的区域(yield results
),因此我们的模板很简单:app/templates/components/list-filter.hbs
:
{ { input value=value
key-up=(action 'handleFilterEntry')
class="light"
placeholder="Filter By City" } }
{ { yield results } }
该模板包含一个{ { input } }
助手用于渲染输入框,用户可以在其中输入过滤使用的城市。输入框的value
属性将与组件的value
属性保持同步。
以另一种说法是,输入框的value
属性绑定到组件的value
属性。如果属性更改,无论是用户输入,还是通过程序为其分配一个新值,该属性的新值将渲染到网页和体现在代码中。key-up
属性将绑定到handleFilterEntry
动作。
这是组件的JavaScript的代码(app/components/list-filter.js
):
import Ember from 'ember';
export default Ember.Component.extend({
classNames: ['list-filter'],
value: '',
init() {
this._super(...arguments);
this.get('filter')('').then((results) => this.set('results', results));
},
actions: {
handleFilterEntry() {
let filterInputValue = this.get('value');
let filterAction = this.get('filter');
filterAction(filterInputValue).then((filterResults) => this.set('results', filterResults));
}
}
});
基于输入过滤数据
在上面的例子中,我们使用init
钩子来初始化租赁列表,具体做法是以空值为过滤条件调用filter
动作。在handleFilterEntry
动作中,调用filter
函数,函数参数是输入助手的value值。
该filter
函数由调用对象传入。这是一种被称为关闭动作的模式。
注意对then
函数的调用使用了filter
函数的调用结果。该代码期望filter
函数返回一个promise。promise是JavaScript对象,它表示一个异步函数的结果。promise在收到时可能已经执行,也可能没有执行。为了解决这个问题,它提供了一些函数,如then
函数可以你在promise返回结果时运行一些代码。
要实现这个filter
函数来实现城市租赁的实际过滤器,我们将创建一个rentals
控制器。 控制器包含可用于其对应路由的模板的操作和属性。在我们的例子中,我们要生成一个名为rentals
的控制器。Ember知道名称为rentals
的控制器将应用于相同名称的路由。
下列命令为rentals
路由生成控制器:
$ ember g controller rentals
installing controller
create app/controllers/rentals.js
installing controller-test
create tests/unit/controllers/rentals-test.js
现在定义新的控制器(app/controllers/rentals.js
):
import Ember from 'ember';
export default Ember.Controller.extend({
actions: {
filterByCity(param) {
if (param !== '') {
return this.get('store').query('rental', { city: param });
} else {
return this.get('store').findAll('rental');
}
}
}
});
当用户在组件中的文本框输入时,控制器中的filterByCity
动作会被调用。此动作取得value
属性(来自用户的输入),并过滤rental
数据存储中与value
匹配的数据。查询的结果返回给调用者。
伪造查询结果
为了使此动作正常工作,我们需要用下来代码覆盖Mirageconfig.js
文件,以便它可以响应我们的查询。rentals
的Mirage HTTP GET处理程序不再简单地返回租借列表,而是根据URL中的city
参数中返回匹配的租赁清单。mirage/config.js
:
export default function() {
this.namespace = '/api';
let rentals = [{
type: 'rentals',
id: 'grand-old-mansion',
attributes: {
title: 'Grand Old Mansion',
owner: 'Veruca Salt',
city: 'San Francisco',
"property-type": 'Estate',
bedrooms: 15,
image: 'https://upload.wikimedia.org/wikipedia/commons/c/cb/Crane_estate_(5).jpg',
description: "This grand old mansion sits on over 100 acres of rolling hills and dense redwood forests."
}
}, {
type: 'rentals',
id: 'urban-living',
attributes: {
title: 'Urban Living',
owner: 'Mike Teavee',
city: 'Seattle',
"property-type": 'Condo',
bedrooms: 1,
image: 'https://upload.wikimedia.org/wikipedia/commons/0/0e/Alfonso_13_Highrise_Tegucigalpa.jpg',
description: "A commuters dream. This rental is within walking distance of 2 bus stops and the Metro."
}
}, {
type: 'rentals',
id: 'downtown-charm',
attributes: {
title: 'Downtown Charm',
owner: 'Violet Beauregarde',
city: 'Portland',
"property-type": 'Apartment',
bedrooms: 3,
image: 'https://upload.wikimedia.org/wikipedia/commons/f/f7/Wheeldon_Apartment_Building_-_Portland_Oregon.jpg',
description: "Convenience is at your doorstep with this charming downtown rental. Great restaurants and active night life are within a few feet."
}
}];
this.get('/rentals', function(db, request) {
if(request.queryParams.city !== undefined) {
let filteredRentals = rentals.filter(function(i) {
return i.attributes.city.toLowerCase().indexOf(request.queryParams.city.toLowerCase()) !== -1;
});
return { data: filteredRentals };
} else {
return { data: rentals };
}
});
}
修改了 mirage 配置后,在应用的首页上显示了输入框,可以按输入过滤城市。
在我们的示例中,您可能会注意到,如果快速输入结果可能与输入的当前过滤器文本不同步。这是因为我们的数据过滤功能是异步的,这意味着函数中的代码将被调度为稍后执行,而调用该函数的代码将继续执行。通常,可能使网络请求的代码设置为异步,因为服务器可能会在不同的时间返回其响应。
让我们添加一些保护代码,以避免查询结果与过滤器输入不同步。为此,我们将简单地将过滤器文本提供给过滤器函数,以便当结果返回时,我们可以将原始过滤器值与当前过滤器值进行比较。只有原始过滤器值和当前过滤器值相同,我们才会在屏幕上更新结果。 app/controllers/rentals.js
(注释掉的是原来的代码):
import Ember from 'ember';
export default Ember.Controller.extend({
actions: {
filterByCity(param) {
if (param !== '') {
// return this.get('store').query('rental', { city: param });
return this.get('store')
.query('rental', { city: param }).then((results) => {
return { query: param, results: results };
});
} else {
// return this.get('store').findAll('rental');
return this.get('store')
.findAll('rental').then((results) => {
return { query: param, results: results };
});
}
}
}
});
在上述filterByCity
租赁控制器的函数中,我们添加了一个新的属性query
,而不是像以前一样返回一系列租赁。app/components/list-filter.js
:
import Ember from 'ember';
export default Ember.Component.extend({
classNames: ['list-filter'],
value: '',
init() {
this._super(...arguments);
// this.get('filter')('').then((results) => this.set('results', results));
this.get('filter')('').then((allResults) => {
this.set('results', allResults.results);
});
},
actions: {
handleFilterEntry() {
let filterInputValue = this.get('value');
let filterAction = this.get('filter');
// filterAction(filterInputValue).then((filterResults) => this.set('results', filterResults));
filterAction(filterInputValue).then((resultsObj) => {
if (resultsObj.query === this.get('value')) {
this.set('results', resultsObj.results);
}
});
}
}
});
在我们的列表过滤器组件JavaScript中,我们使用该query
属性来比较组件的value
属性。该value
属性表示输入字段的最新状态。因此,我们现在检查结果与输入字段是否匹配,确保结果与用户输入保持同步。
虽然这种方法将使我们的结果顺序保持一致,但在处理多个并发任务时还需要考虑其他问题,例如限制对服务器发出的请求数量。为了为应用程序创建有效和强大的自动完成行为,我们建议您考虑使用ember-concurrencyaddon项目。
您现在可以继续执行下一个功能,或继续测试我们新创建的过滤器组件。
集成测试
tests/integration/components/list-filter-test.js
:
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import wait from 'ember-test-helpers/wait';
import RSVP from 'rsvp';
moduleForComponent('list-filter', 'Integration | Component | filter listing', {
integration: true
});
const ITEMS = [{city: 'San Francisco'}, {city: 'Portland'}, {city: 'Seattle'}];
const FILTERED_ITEMS = [{city: 'San Francisco'}];
test('should initially load all listings', function (assert) {
// we want our actions to return promises, since they are potentially fetching data asynchronously
this.on('filterByCity', () => {
return RSVP.resolve({ results: ITEMS });
});
// with an integration test,
// you can set up and use your component in the same way your application will use it.
this.render(hbs`
{ { #list-filter filter=(action 'filterByCity') as |results| } }
<ul>
{ { #each results as |item| } }
<li class="city">
{ { item.city } }
</li>
{ { /each } }
</ul>
{ { /list-filter } }
`);
return wait().then(() => {
assert.equal(this.$('.city').length, 3);
assert.equal(this.$('.city').first().text().trim(), 'San Francisco');
});
});
由于我们的组件期望过滤器过程是异步的,我们使用Ember的RSVP库从我们的过滤器返回promise。 this.on
将提供的filterByCity
函数添加到测试本地范围,我们可以使用它来提供给组件。filterByCity
函数将被装扮成为我们组件的动作函数,实际过滤出租列表。
测试的最后,添加了一个wait
调用来检验返回结果。
Ember的wait助手 在运行给定的函数回调之前等待所有异步任务完成。它返回一个从测试返回的promise。
第一测试模拟了空值过滤,返回了所有城市的租赁列表。下面是第二个测试,它将模仿用户输入过滤条件,检测返回的租赁列表是否符合输入的城市。
我们将为filterByCity
动作添加一些附加功能,以返回单个租赁,FILTERED_ITEMS
变量就是设置的过滤条件。
我们通过keyUp
在输入字段上生成一个事件来强制执行该操作,然后检测确保只渲染一个项目。tests/integration/components/list-filter-test.js
:
test('should update with matching listings', function (assert) {
this.on('filterByCity', (val) => {
if (val === '') {
return RSVP.resolve({
query: val,
results: ITEMS });
} else {
return RSVP.resolve({
query: val,
results: FILTERED_ITEMS });
}
});
this.render(hbs`
{ { #list-filter filter=(action 'filterByCity') as |results| } }
<ul>
{ { #each results as |item| } }
<li class="city">
{ { item.city } }
</li>
{ { /each } }
</ul>
{ { /list-filter } }
`);
// The keyup event here should invoke an action that will cause the list to be filtered
this.$('.list-filter input').val('San').keyup();
return wait().then(() => {
assert.equal(this.$('.city').length, 1);
assert.equal(this.$('.city').text().trim(), 'San Francisco');
});
});
现在两个集成测试场景都应该能通过。您可以通过ember t -s
命令来启动我们的测试套件来验证这一点。
验收测试
现在我们已经测试了list-filter
组件的行为与预期的一样,让我们来测试一下,页面本身也可以正常地进行验收测试。我们会验证访问租借页面的用户可以在搜索字段中输入文字,并按城市缩小租赁列表。
打开我们现有的验收测试,tests/acceptance/list-rentals-test.js
并实施标签为“should filter the list of rentals by city”的测试。 tests/acceptance/list-rentals-test.js
:
test('should filter the list of rentals by city.', function (assert) {
visit('/');
fillIn('.list-filter input', 'Seattle');
keyEvent('.list-filter input', 'keyup', 69);
andThen(function() {
assert.equal(find('.listing').length, 1, 'should show 1 listing');
assert.equal(find('.listing .location:contains("Seattle")').length, 1, 'should contain 1 listing with location Seattle');
});
});
我们在测试中引入了两个新的帮手,fillIn
和keyEvent
。
fillIn
助手“填写”给定的文本到给定的选择相匹配的输入字段。keyEvent
助手发送键击事件的UI,模拟用户输入一个按键。
在app/components/list-filter.js
中,我们有一个被类型是list-filter
的组件渲染出来的顶层元素。我们使用选择器在组件内定位搜索输入.list-filter input
,因为我们知道列表过滤器组件中只有一个输入元素。
我们的测试填写“Seattle”作为搜索字段中的搜索条件,然后keyup
使用69
(字母e
的按键值)的代码将事件发送到同一个字段,以模拟用户输入。
在测试中通过查找类型是listing
的元素,定位出在本教程的“构建简单组件”部分中定义的rental-listing
组件。
由于我们的数据在Mirage中是硬编码的,所以我们知道只有一个城市名称为“Seattle”的租金,所以我们断定数量是一个,它显示的位置被命名为“Seattle”。
测试验证在填写“Seattle”搜索输入后,租赁列表从3减少到1,显示的项目显示“Seattle”作为位置。
按原文,到现在应只剩下2个验收测试失败,但我测试3个失败。