For Super Rentals, we want to be able to display a map showing where each rental is. We will use an Ember service to implement this feature.
We plan to use the following services to provide maps.
- A service that creates a map from our third party map API.
- A service that fetches the GPS coordinates of a rental from its location name by consulting a geocoding service.
- A service that provides a map to a given element based on a location, and that keeps a cache of rendered maps to use in different places in the application.
The first two services are provided through an Ember addon we plan to install called ember-simple-leaflet-maps.
We will use these two services in the third service that we will create here as part of the tutorial.
Making Maps Available
Before implementing a map, we need to make a 3rd party map API available to our Ember app. There are several ways to include 3rd party libraries in Ember. See the guides section on managing dependencies as a starting point when you need to add one.
For the purpose of this tutorial we will take advantage of the Leaflet Maps API
The Leaflet Maps API requires us to reference its library from a remote script.
In this case we'll provide this script to our Ember app via an addon called ember-simple-leaflet-maps
.
ember install ember-simple-leaflet-maps
The addon makes use of street images generated by Mapbox. Using the Mapbox API requires an access token. You can Request an access token for free from Mapbox.
Once installed, add the following line to mirage/config.js
:
this.passthrough('https://api.mapbox.com/**');
This tells mirage to let our maps calls go through to the server.
Next, add your new API key to the application by stopping the server and restarting it with the environment variable, LEAFLET_MAPS_API_KEY
.
LEAFLET_MAPS_API_KEY=<your key here> ember s
The application should start and work as we had it before. We will now have the APIs available we need to be able to display maps for our properties.
Fetching Maps With a Service
Now that we have Map APIs,
we will implement a map element service that will create an HTML div
element and generate a map to it based on the location we provide.
The service will also keep a reference to the element we create,
so that a map for a given location will only have to be generated once.
Accessing our maps API through a service will give us several benefits:
- It is injected with a service locator, meaning it will abstract the maps API from the code that uses it, allowing for easier refactoring and maintenance.
- It is lazy-loaded, meaning it won't be initialized until it is called the first time. In some cases this can reduce your app's processor load and memory consumption.
- It is a singleton, which means there is only one instance of the service object in the browser. This will allow us to keep map data while the user navigates around the app, so that returning to a page doesn't require it to reload its maps.
Let's get started creating our service by generating it through Ember CLI, which will create the service file, as well as a unit test for it.
ember g service map-element
Now implement the service as follows.
The main API will be an async function called getMapElement
,
that returns an HTML element containing a rendered map for the given location.
We want the function to be async so we can call the remote geocode API and wait for its result.
Notice the await
keyword in the code below when we call geocode.fetchCoordinates
.
Note that we check if a map already exists for the given location and use that one, otherwise we will create a new HTML element and call our Leaflet map service to render a map to it.
import { camelize } from '@ember/string';
import Service from '@ember/service';
import { set } from '@ember/object';
import { inject as service } from '@ember/service';
export default Service.extend({
geocode: service(),
map: service(),
init() {
if (!this.cachedMaps) {
set(this, 'cachedMaps', {});
}
this._super(...arguments);
},
async getMapElement(location) {
let camelizedLocation = camelize(location);
let element = this.cachedMaps[camelizedLocation];
if (!element) {
element = this._createMapElement();
let geocodedLocation = await this.geocode.fetchCoordinates(location);
this.map.createMap(element, geocodedLocation);
this.cachedMaps[camelizedLocation] = element;
}
return element;
},
_createMapElement() {
let element = document.createElement('div');
element.className = 'map';
return element;
},
});
Display Maps With a Component
With a service that renders a map to a web page element, we can connect it to our application's DOM using a component.
Generate the map component using Ember CLI.
ember g component location-map
Running this command generates three files: a component JavaScript file, a template, and a test file.
We provide the maps service into our component by initializing a property of our component, called mapElement
.
Services are commonly made available in components and other Ember objects by "service injection".
When you initialize a property with import { inject } from '@ember/service';
,
Ember tries to set that property with a service matching its name.
With our mapElement
service, our component will call the getMapElement
function with the provided location.
We append the map element we get back from the service by implementing didInsertElement
,
which is a component lifecycle hook.
This function runs during the component render, after the component's markup gets inserted into the page.
import Component from '@ember/component';
import { inject as service } from '@ember/service';
export default Component.extend({
classNames: ['map-container'],
mapElement: service(),
didInsertElement() {
this._super(...arguments);
this.mapElement.getMapElement(this.location).then((mapElement) => {
this.element.append(mapElement);
});
}
});
You may have noticed that this.location
refers to a property location we haven't defined.
This property will be passed in to the component by its parent template below.
Finally open the template file for our rental-listing
component and add the new LocationMap
component.
After starting the server we should now see some end to end maps functionality show up on our front page!
You may now either move onto the next feature, or continue here to test the maps feature we just added.
Tests
Unit testing a Service
We'll use a unit test to validate the service.
Unit tests are more isolated than integration tests and application tests,
and are intended for testing specific logic within a class.
(Note: you should restart ember test --server
whenever you add a new service, otherwise the new service will not be available to your unit tests).
For our service unit test, we'll want to verify that locations that have been previously loaded are fetched from cache, while new locations are created the third party map service.
We will isolate our tests from actually calling Leaflet Maps by stubbing the map service.
On line 20 of map-element-test.js
below we create a JavaScript object to simulate the behavior of the utility, but instead of creating a Google map, we return an empty JavaScript object.
To instantiate the service, we can instantiate it through ember's resolver using the factoryFor
method.
factoryFor
allows us to have control over the creation of the service in Ember, to pass arguments to the constructor that can override parts of the service for our tests.
For cases where we do not need to override parts of the service, we can use lookup
In our test below we are passing in our fake map utility object in the first test, and passing a cache object for the second test.
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
const DUMMY_ELEMENT = {};
module('Unit | Service | maps', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let service = this.owner.lookup('service:maps');
assert.ok(service);
});
test('should create a new map if one isnt cached for location', async function (assert) {
assert.expect(5);
let stubMapService = {
createMap(element, coords) {
assert.ok(element, 'createMap called with element');
assert.deepEqual(coords, [0, 0], 'createMap given coordinates');
return DUMMY_ELEMENT;
}
}
let stubGeocodeService = {
fetchCoordinates(location) {
assert.equal(location, 'San Francisco', 'fetchCoordinates called with location');
return Promise.resolve([0, 0]);
}
}
let mapService = this.owner.factoryFor('service:map-element').create({map: stubMapService, geocode: stubGeocodeService});
let element = await mapService.getMapElement('San Francisco');
assert.ok(element, 'element exists');
assert.equal(element.className, 'map', 'element has class name of map');
});
test('should use existing map if one is cached for location', async function (assert) {
assert.expect(1);
let stubCachedMaps = {
sanFrancisco: DUMMY_ELEMENT
};
let mapService = this.owner.factoryFor('service:map-element').create({ cachedMaps: stubCachedMaps });
let element = await mapService.getMapElement('San Francisco');
assert.deepEqual(element, DUMMY_ELEMENT, 'element fetched from cache');
});
});
When the service calls createMap
on our fake utility stubMapService
, we will run asserts to validate that it is called.
In our first test notice that we expect five asserts to be run in line 15. Two of the asserts run in the test function, while the other two are run when createMap
is called.
In the second test, only one assert is expected (line 36), since the map element is fetched from cache and does not use the utility.
Also, note that the second test uses a dummy object as the returned map element (defined on line 4). Our map element can be substituted with any object because we are only asserting that the cache has been accessed (see line 42).
The location in the cache has been camelized
(line 38),
so that it may be used as a key to look up our element.
This matches the behavior in getMapElement
when city has not yet been cached.
Integration Testing the Map Component
Now let's test that the map component is relying on our service to provide map elements.
To limit the test to validating only its own behavior and not the service, we'll take advantage of the registration API to register a stub maps service. That way when Ember injects the map service into the component, it uses our fake service instead of the real one.
A stub stands in place of the real object in your application and simulates its behavior.
In the stub service, define a method that will fetch the map based on location, called getMapElement
.
import Service from '@ember/service';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
let StubMapsService = Service.extend({
getMapElement(location) {
this.set('calledWithLocation', location);
let element = document.createElement('div');
element.className = 'map';
return Promise.resolve(element);
}
});
module('Integration | Component | location map', function(hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function() {
this.owner.register('service:map-element', StubMapsService);
this.mapsService = this.owner.lookup('service:map-element');
});
test('should append map element to container element', async function(assert) {
this.set('myLocation', 'New York');
await render(hbs`<LocationMap @location={{myLocation}} />`);
assert.ok(this.element.querySelector('.map-container > .map'), 'container should have map child');
assert.equal(this.get('mapsService.calledWithLocation'), 'New York', 'should call service with New York');
});
test('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<LocationMap />`);
assert.equal(this.element.textContent.trim(), '');
// Template block usage:
await render(hbs`
<LocationMap>
template block text
</LocationMap>
`);
assert.equal(this.element.textContent.trim(), 'template block text');
});
});
In the beforeEach
function that runs before each test, we use the built-in owner
object.
An owner
has two functions, register
and lookup
.
Use the function this.owner.register
to register our stub service in place of the maps service.
Registration makes an object available to your Ember application for things like loading components from templates and injecting services in this case.
The call to the function this.owner.lookup
looks up the service we just registered and returns the instance that the test will use.
In the example we assert that calledWithLocation
in our stub is set to the location we passed to the component.
We'll want to also stub the maps service for our RentalListing
rendering test,
since it uses LocationMap
in its template.
import Service from '@ember/service';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import EmberObject from '@ember/object';
let StubMapsService = Service.extend({
getMapElement() {
return Promise.resolve(document.createElement('div'));
}
});
module('Integration | Component | rental listing', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:map-element', StubMapsService);
this.rental = EmberObject.create({
this.rental = {
image: 'fake.png',
title: 'test-title',
owner: 'test-owner',
type: 'test-type',
city: 'test-city',
bedrooms: 3
});
}
});
test('should display rental details', async function(assert) {
await render(hbs`<RentalListing @rental={{this.rental}} />`);
assert.equal(this.element.querySelector('.listing h3').textContent.trim(), 'test-title', 'Title: test-title');
assert.equal(this.element.querySelector('.listing .owner').textContent.trim(), 'Owner: test-owner', 'Owner: test-owner');
});
test('should toggle wide class on click', async function(assert) {
await render(hbs`<RentalListing @rental={{this.rental}} />`);
assert.notOk(this.element.querySelector('.image.wide'), 'initially rendered small');
await click('.image');
assert.ok(this.element.querySelector('.image.wide'), 'rendered wide after click');
await click('.image');
assert.notOk(this.element.querySelector('.image.wide'), 'rendered small after second click');
});
});
Stubbing Services in Application Tests
Finally, we want to update our application tests to account for our new service. While it would be great to verify that a map is displaying, we don't want to hammer the Maps service every time we run our application test. For this tutorial we'll rely on our component integration tests to ensure that the map DOM is being attached to our screen. To avoid hitting our Maps request limit, we'll stub out our Maps service in our application tests.
Often, services connect to third party APIs that are not desirable to include in automated tests. To stub these services we simply have to register a stub service that implements the same API, but does not have the dependencies that are problematic for the test suite.
Add the following code to your application test
import Service from '@ember/service';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import {
click,
currentURL,
visit,
fillIn,
triggerKeyEvent
} from '@ember/test-helpers'
let StubMapsService = Service.extend({
getMapElement() {
return Promise.resolve(document.createElement('div'));
}
});
module('Acceptance | list rentals', function(hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function() {
this.owner.register('service:map-element', StubMapsService);
});
...
});
What's happening here is we are adding our own stub maps service that simply creates an empty div. Then we are putting it in Ember's registry using the owner object given by the test context. When our component loads the maps service, it gets our stub service instead. That way every time that component is created, our stub map service gets injected over the map-element service. Now when we run our application tests, you'll notice that maps do not get rendered as the test runs.