Quando nossos usuários estão procurando um imóvel, eles precisam filtrar a pesquisa por uma cidade especifica, por exemplo.
Nosso component rental-listing
apenas mostrava informações sobre o imóvel, esse novo component vai permitir que nosso usuário consiga filtrar imóveis por cidade.
Para começar, vamos gerar o novo component.
Chamaremos esse component de list-filter
, já que tudo o que precisamos é que ele filtre os imóveis disponível.
ember g component list-filter
Assim como o component rental-listing
, o comando ember generate component
vai criar:
- um arquivo de template (
app/templates/components/list-filter.hbs
), - um arquivo JavaScript (
app/templates/components/list-filter.hbs
), - e um arquivo de teste de integração (
tests/integration/components/list-filter-test.js
).
Vamos adicionar nosso component list-filter
em nosso arquivo app/templates/rentals.hbs
.
Observe que vamos envolver nossa listagem de imóveis dentro do component list-filter
, nas linhas 12 e 20.
Esse é um exemplo de block form, que permite que o template Handlebars seja renderizado inside, dentro do component list-filter
na expressão {{yield}}
.
Neste caso, estamos passando yielding
, o resultado do nosso filtro para dentro da marcação interna, através da variável rentals
(linha 14).
<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}}
{{#each model as |rentalUnit|}}
{{rental-listing rental=rentalUnit}}
{{/each}}
Queremos que o component simplesmente tenha um campo (input) e envie o resultado para a expressão {{yield}}
.
Observer que nosso template agora possui um novo tipo de helper {{input}}
, ele funciona como um campo de texto, no qual nosso usuário poderá digitar uma cidade e filtrar o resultado de imóveis.
A propriedade value
do input
será sincronizada com a propriedade value
do component.
Outra maneira de dizer isso é que a propriedade value
do input
é bound com a propriedade value
do component.
A propriedade key-up
será vinculada à action handleFilterEntry
.
Aqui está como nosso código JavaScript do component deve ficar:
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));
}
}
});
No exemplo acima, usamos o método hook init
para criar nossas lista de imóveis iniciais chamando a função filter
com um valor vazio.
Nossa action handleFilterEntry
chama uma função chamada filter
com base no valor do atributo value
.
A função filter
foi passada como objeto. Este é um padrão conhecido como closure actions.
Observe a função then
chamada no resultado da função filter
.
O código espera que a função filter
responda uma Promise.
Uma Promise é um objeto JavaScript que representa o resultado de uma função assíncrona.
Uma promise pode ou não ser executada no momento em que você a declara.
Em nosso exemplo, fornecemos a função then
que permite que seja executado somente quando a promise finalizar e devolver o resultado.
Para que a função filter
faça a filtragem dos imóveis de acordo com a cidade, criaremos um controller chamado rental
.
Controllers contêm actions e propriedades disponíveis para nosso template.
Como Ember trabalha por convenções, ele saberá que um controller chamado rental
pertence a uma route com o mesmo nome.
Crie um controller para a route rental
executando o seguinte:
ember g controller rentals
Agora, podemos adicionar a action filterByCity
ao controller:
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');
}
}
}
});
Quando o usuário digitar no campo de texto em nosso component, a action filterByCity
no controller será chamada.
Essa action aceita a propriedade value
e filtra os dados de rental
de acordo com a cidade que o usuário digitou.
O resultado da consulta é retornado para quem o chamou.
Para que esta action funcione, precisamos substituir no arquivo mirage/config.js
no Mirage com o seguinte, para que ele possa devolver o resultado de acordo com nossa consulta.
Em vez de simplesmente retornar a lista de imóveis, nosso manipulador Mirage HTTP GET rentals
retornará os imóveis correspondente à string fornecida no parâmetro city
na URL.
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 };
}
});
}
Depois de atualizar as configurações do Mirage, devemos conseguir ver o resultado sendo filtrado a medida que vamos digitando no campo de texto.
Se você digitar rapidamente no campo de texto, você verá que o resultado apresentado é mostrado de forma confusa em tempo diferente. Isso ocorre porque nossa função que faz a filtragem é synchronous, o que significa que o código na função é agendado para mais tarde, enquanto o código que chama a função continua a ser executado. Muitas vezes, o código que faz solicitações na rede está configurado para ser assíncrono porque o servidor pode retornar as respostas em horários variáveis.
Vamos adicionar um código simples para garantir que nossos resultados sejam sincronizados de acordo com o valor do filtro. Para fazer isso, simplesmente forneceremos o texto do filtro para a função de filtro, de modo que, quando os resultados retornarem, podemos comparar o valor do filtro original com o valor do filtro atual. Vamos atualizar o resultado na tela somente se o valor do filtro original e o valor do filtro atual forem iguais.
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 };
});
}
}
}
});
A action filterByCity
no controller rental
acima, adicionamos uma nova propriedade chamada query
aos resultados do filtro em vez de apenas retornar um array de imóveis como antes.
import Ember from 'ember';
export default Ember.Component.extend({
classNames: ['list-filter'],
value: '',
init() {
this._super(...arguments);
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((resultsObj) => {
if (resultsObj.query === this.get('value')) {
this.set('results', resultsObj.results);
}
});
}
}
});
No nosso component list-filter
, usamos a propriedade query
para comparar com a propriedade value
do component.
A propriedade value
representa o estado mais recente do filtro.
Portanto, verificamos se os resultados correspondem ao valor do filtro, garantindo que os resultados permanecerão em sincronia com a última coisa que o usuário digitou.
Embora esta abordagem mantenha nossa ordem de resultados consistente, há outras coisas a considerar ao lidar com várias tarefas simultâneas, como limitar o número de solicitações feitas ao servidor.
Para criar um comportamento de autocomplete eficaz e robusto para suas aplicações, recomendamos considerar utilizar o addon ember-concurrency
.
Agora você pode avançar para próxima página ou continuar nesta página e fazer os teste de integração e aceitação.
Agora que criamos um novo component list-filter
, precisamos criar testes para verificar que tudo funcione corretamente no futuro.
Vamos usar component integration test para verificar o comportamento do component, semelhante ao teste criado para a listagem de imóveis.
Comece abrindo o arquivo de teste do component list-filter
criado anteriormente tests/integration/components/list-filter-test.js
.
Remova o teste padrão e crie um novo teste que verifique se o component irá listar todos os imóveis.
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
moduleForComponent('list-filter', 'Integration | Component | filter listing', {
integration: true
});
test('should initially load all listings', function (assert) {
});
Nosso component list-filter
recebe como argumento uma função, usada para retornar a lista de imóveis que corresponde a cidade digitada pelo usuário.
Para simular o comportamento da action filterByCity
definida no controller rental
, vamos criar uma action no escopo local usando this.on
.
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import RSVP from 'rsvp';
const ITEMS = [{city: 'San Francisco'}, {city: 'Portland'}, {city: 'Seattle'}];
const FILTERED_ITEMS = [{city: 'San Francisco'}];
moduleForComponent('list-filter', 'Integration | Component | filter listing', {
integration: true
});
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 });
});
});
this.on
irá adicionar a função fornecida ao escopo local de teste como filterByCity
, que podemos usar no component.
Nossa função filterByCity
será a action que nosso component irá chamar para retornar a lista de imóveis filtrada.
Não estamos testando a filtragem real dos imóveis neste teste, pois estamos focando apenas no comportamento do component. Vamos testar a lógica completa de filtragem nos testes de aceitação, descritos na próxima seção.
Uma vez que nosso component está esperando que a filtragem seja assíncrona, retornaremos uma Promise com o filtro de imóveis, usando a Ember RSVP library.
Em seguida, adicionaremos a chamada para renderizar o component e mostrar as cidades que fornecemos acima.
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import RSVP from 'rsvp';
const ITEMS = [{city: 'San Francisco'}, {city: 'Portland'}, {city: 'Seattle'}];
const FILTERED_ITEMS = [{city: 'San Francisco'}];
moduleForComponent('list-filter', 'Integration | Component | filter listing', {
integration: true
});
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}}
`);
});
Finalmente, adicionamos uma chamada de wait
no final do nosso teste para verificar os resultados.
Ember wait helper aguarda que todas as tarefas assíncronas sejam concluídas antes de executar o retorno da função. Ele retorna uma promise igual a que utilizamos no teste.
Se você retornar uma promise de um teste QUnit, o teste aguardará até que a promise finalize.
Nesse caso, nosso teste é concluído quando o helper wait
decide que o processamento está concluído, e a função que fornecemos que afirma que o estado resultante está concluído.
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');
});
});
Para o nosso segundo teste, verificaremos que o texto digitado no filtro realmente chamará adequadamente a action de filtragem e atualizará a listagem corretamente.
Nós adicionaremos algumas funcionalidades adicionais à nossa action filterByCity
para retornar um único imóvel, representado pela variável FILTERED_ITEMS
quando qualquer valor estiver definido.
Forçamos a action gerando um evento keyUp
em nosso campo de pesquisa, e depois verificamos que apenas um item é renderizado.
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');
});
});
Agora, ambos os cenários de teste de integração devem está passando.
Você pode verificar isso executando ember t -s
no terminal.
Agora que testamos que o component list-filter
se comporta como esperado, vamos testar que a própria página também se comporte adequadamente com um teste de aceitação.
Verificaremos que um usuário que visite a página de imóveis pode digitar no campo de pesquisa e filtrar a lista de imóveis por cidade.
Abra nosso teste de aceitação existente, tests/acceptance/list-rentals-test.js
e implemente o teste chamado "should filter the list of rentals by city".
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');
});
});
Apresentamos dois novos helper neste teste, fillIn
e keyEvent
.
fillIn
preenche o campo de texto com um valor, correspondente ao seletor fornecido.keyEvent
envia um evento de tecla para a interface do usuário, simulando o usuário digitando o valor.
Em app/components/list-filter.js
, temos como elemento de nível superior representado pelo component uma classe chamada list-filter
.
Localizamos a entrada de pesquisa dentro do component usando o seletor .list-filter input
, pois sabemos que existe apenas um campo de texto localizado no component list-filter
.
Nosso teste preenche "Seattle" como o critério no campo de pesquisa e, em seguida, envia um evento keyup
para o mesmo campo com um código 69
para simular a digitação de um usuário.
O teste localiza os resultados da pesquisa encontrando elementos com uma classe de listing
, que nós demos ao nosso component rental-listing
na seção "Construindo um Component Simples".
Uma vez que nossos dados estão codificados em Mirage, sabemos que existe apenas um imóvel na cidade de "Seattle", por isso afirmamos que o número de imóvel é um e que a localização exibida é chamada "Seattle".
O teste verifica que depois de preencher a entrada de pesquisa com "Seattle", a lista de imóveis diminui de 3 para 1 e o item exibido mostra "Seattle" como a localização.
Você deve ter apenas 2 testes falhando: uma falha de teste de aceitação remanescente; e nosso teste ESLint que falha em um assert
não utilizado.