In the first part we prepared a routable modal for the search interface.
Now it’s time to allow a user to interact with that modal and query backend.

Add search form component

1
$ ember generate component search-form

app/components/search-form.js

1
2
import Ember from 'ember';
export default Ember.Component.extend({});

app/templates/components/search-form.hbs

1
2
<h1>Hello!</h1>
<p>What are you searching for?</p>

app/templates/search.hbs

1
2
3
{{#full-screen-modal-dialog}}
{{search-box}}
{{/full-screen-modal-dialog}}

tests/integration/components/search-box-test.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { test, moduleForComponent } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';

moduleForComponent('search-box', 'Integration | Component | search box', {
integration: true
});

test('it renders', function(assert) {
assert.expect(2);

this.render(hbs `{{search-box}}`);

assert.equal(this.$('h1').text().trim(), 'Hello!');
assert.equal(this.$('p:first').text().trim(), "What are you searching for?");
});

Now it’s time to make this component able to act.
We add an acceptance test first as it’s very easy to describe what we want to achieve.

tests/acceptance/search-test.js

1
2
3
4
5
6
7
8
9
10
test('search by query', function(assert) {
visit('/search');

fillIn('input.search-query', 'watermelon');
click('input.submit-button');

andThen(function() {
assert.equal(currentURL(), '/search?query=watermelon');
});
});

app/components/search-box.js

1
2
3
4
5
6
7
8
9
10
import Ember from 'ember';
export default Ember.Component.extend({
query: "",

actions: {
updateParams() {
this.sendAction('updateParams') // or this.attrs.updateParams();
}
}
});

app/controllers/search.js

1
2
3
4
5
6
7
8
9
10
11
12
import Ember from 'ember'

export default Ember.Controller.extend({
queryParams: ['query'],
query: null,

actions: {
updateParams() {
this.transitionToRoute('search', { queryParams: { query: @get('query') }});
}
}
});

And finally:

app/templates/components/search-box.hbs

1
2
3
4
5
6
7
<h1>Hello!</h1>
<p>What are you searching for?</p>

<form class="form-horizontal" {{action "updateParams" on="submit"}}>
{{input class="search-query" value=query}}
<input class="submit-button" type="submit" value="Submit">
</form>

app/templates/search.hbs

1
2
3
{{#modal-dialog}}
{{search-box updateParams=(action "updateParams") query=query}}
{{/modal-dialog}}

On the end action can be tested in component integration test this way:

tests/integration/components/search-box-test.coffee

1
2
3
4
5
6
7
8
9
10
11
12
test('when clicks on submit', function(assert) {
assert.expect(1);

this.set('query', "watermelon");

this.set('updateParams', () => {
assert.ok(true);
});

this.render(hbs `{{search-box query=query updateParams=(action updateParams)}}`);
this.$('input.submit-button').click();
});

So now we have a component that can communicate and pass a query to the router. But there is one bummer! When user types, URL changes instantly. We don’t want to send a query to the backend on each key down event though, so let’s fix it:

Let’s rename the component property, and thanks to this propagate property changes only after submitting our form:

tests/acceptance/search-test.js

1
2
3
4
5
6
7
8
9
test('search by query do not modify url without submit', function(assert) {
visit('/search');

fillIn('input.search-query', 'wate');

andThen(function() {
assert.equal(currentURL(), '/search');
});
});

app/components/search-box.js

1
2
3
4
5
6
7
8
9
10
export default Ember.Component.extend({
searchQuery: "",

actions: {
updateParams() {
this.set('query', this.get('searchQuery')); // that's the trick!
this.sendAction('updateParams');
}
}
});

app/templates/components/search-box.hbs

1
2
3
4
<form class="form-horizontal" {{action "updateParams" on="submit"}}>
{{input class="search-query" value=searchQuery}}
<input class="submit-button" type="submit" value="Submit">
</form>>

Add querying mechanism

That part is pretty simple, it’s just passing data down to the component from model hook:

app/routes/search.js

1
2
3
4
5
6
7
8
9
10
11
12
13
import Ember from 'ember';

export default Ember.Route.extend({
queryParams: {
query: {
refreshModel: true
}
},

model(params) {
return this.store.query('note', { title: params.query });
}
)

I will skip the rest of the code, now let’s focus on:

Mocking backend

I know two reasonable approaches. Okay, technically there are three:

  1. hard-coded array of JavaScript objects in route`s model hook
  2. http-mock https://ember-cli.com/user-guide/#mocks-and-fixtures (check this out too: https://www.npmjs.com/package/ember-cli-testem-http-mocks)
  3. using client-side mock server ember-cli-mirage (http://www.ember-cli-mirage.com/)

As the last one have a great opinion in the community we will choose it.
By the way, it could be used both for development and tests by default, while http-mock requires an additional addon to make it work in the test environment.

Our updated tests/acceptance/search-test.js

1
2
3
4
5
6
7
8
9
10
11
12
13
test('queries notes by title', function(assert) {
server.createList('note', 10) // create records with mirage
visit('/search');

fillIn('input.search-query', 'Note#5');
click('input.submit-button');

andThen(function() {
assert.equal(find('ul.notes li').length, 1);
assert.equal(find('ul.notes li:first').text(), "Note#5");
});
});
});

Now time to make the test pass!

Mirage provides powerful stuff, for example, database methods known from Rails world, for example:
http://www.ember-cli-mirage.com/docs/v0.3.x/database/#where
It will be really useful in our search feature!
By the way, I just found that it could be used to do super spicy things, check
https://github.com/samselikoff/ember-cli-mirage/pull/379 for details.

Let’s dive into code:

mirage/factories/note.js

1
2
3
4
5
import { Factory } from 'ember-cli-mirage';

export default Factory.extend({
title(i) { return 'Note#' + i; }
});

mirage/config.js

1
2
3
4
5
6
7
8
9
10
11
12
export default function() {

this.namespace = '/api';

this.get('/notes/', (schema, request) => {
if(request.queryParams.title === undefined) {
return schema.notes.all();
} else {
return schema.notes.where({title: request.queryParams.title});
}
});
}

Improvements

Speed up rendering the modal

There is also one thing that is not so good. Fetching all data in the model hook blocks the transition. So if there are no Notes in Ember store yet, it could take some time to show a modal filled with data. We want to show a modal smoothly.

This video (https://www.youtube.com/watch?v=kPxiiAGMSzE) from EmberConf 2017 was an inspiration on how to solve this problem elegantly.
Check this out:

app/routes/search.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import Ember from 'ember';

export default Ember.Route.extend({

model() {
Ember.run.schedule('afterRender', this, this._fetchNotes);
},

_fetchNotes() {
let { controller, store } = this;
let { query } = controller;

controller.set('isLoading', true);

if (query) {
store.query('note', { title: query })
.then(this._loadNotes.bind(this))
.finally(this._done.bind(this));
} else {
store.findAll('note', { reload: true })
.then(this._loadNotes.bind(this))
.finally(this._done.bind(this));
}
},

_loadNotes(notes) {
this.controller.set('model', notes);
},

_done() {
this.controller.set('isLoading', false);
}
});

The thing is that we schedule fetching the notes after rendering a template:
(BTW loading text could be replaced with any nice spinner instead)

As you can see even if a long list of notes is fetched, a modal dialog is opened immediately. That was not possible with the previous solution.

Add close button to back to home page

tests/acceptance/search-test.js

1
2
3
4
5
6
7
8
9
test('close search modal', function(assert) {
visit('/search');

click('a.search-close');

andThen(function() {
assert.equal(currentURL(), '/');
});
});

app/templates/search.hbs

1
2
3
4
{{#full-screen-modal-dialog}}
{{#link-to "application" class='search-close'}}Close{{/link-to}}
(...)
{{/full-screen-modal-dialog}}

Simple, right?

Thanks for reading!