Edit Page

Reusable Components


The last missing feature for the <Rental> component is a map to show the location of the rental, which is what we're going to work on next:

The Super Rentals app by the end of the chapter

While adding the map, you will learn about:

  • Managing application-level configurations
  • Parameterizing components with arguments
  • Accessing component arguments
  • Interpolating values in templates
  • Overriding HTML attributes in ...attributes
  • Refactoring with getters and auto-track
  • Getting JavaScript values into the test context

Managing Application-level Configurations

We will use the Mapbox API to generate maps for our rental properties. You can sign up for free and without a credit card.

Mapbox provides a static map images API, which serves map images in PNG format. This means that we can generate the appropriate URL for the parameters we want and render the map using a standard <img> tag. Pretty neat!

If you're curious, you can explore the options available on Mapbox by using the interactive playground.

Once you have signed up for the service, grab your default public token and paste it into config/environment.js:

'use strict';

module.exports = function(environment) {
  let ENV = {
    modulePrefix: 'super-rentals',
    environment,
    rootURL: '/',
    locationType: 'auto',
    EmberENV: {
      RAISE_ON_DEPRECATION: true,
      FEATURES: {
        // Here you can enable experimental features on an ember canary build
        // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true
      },
      EXTEND_PROTOTYPES: {
        // Prevent Ember Data from overriding Date.parse.
        Date: false
      }
    },

    APP: {
      // Here you can pass flags/options to your application instance
      // when it is created
    }
  };

  if (environment === 'development') {
    // ENV.APP.LOG_RESOLVER = true;
    // ENV.APP.LOG_ACTIVE_GENERATION = true;
    // ENV.APP.LOG_TRANSITIONS = true;
    // ENV.APP.LOG_TRANSITIONS_INTERNAL = true;
    // ENV.APP.LOG_VIEW_LOOKUPS = true;
  }

  if (environment === 'test') {
    // Testem prefers this...
    ENV.locationType = 'none';

    // keep test console output quieter
    ENV.APP.LOG_ACTIVE_GENERATION = false;
    ENV.APP.LOG_VIEW_LOOKUPS = false;

    ENV.APP.rootElement = '#ember-testing';
    ENV.APP.autoboot = false;
  }

  if (environment === 'production') {
    // here you can enable a production-specific feature
  }

  ENV.MAPBOX_ACCESS_TOKEN = 'paste your Mapbox access token here';

  return ENV;
};

As its name implies, config/environment.js is used to configure our app and store API keys like these. These values can be accessed from other parts of our app, and they can have different values depending on the current environment (which might be development, test, or production).

Zoey says...

If you prefer, you can create different Mapbox access tokens for use in different environments. At a minimum, the tokens will each need to have the "styles:tiles" scope in order to use Mapbox's static images API.

After saving the changes to our configuration file, we will need to restart our development server to pick up these file changes. Unlike the files we have edited so far, config/environment.js is not automatically reloaded.

You can stop the server by finding the terminal window where ember server is running, then type Ctrl + C. That is, typing the "C" key on your keyboard while holding down the "Ctrl" key at the same time. Once it has stopped, you can start it back up again with the same ember server command.

$ ember server
building... 

Build successful (13286ms) – Serving on http://localhost:4200/

Generating a Component with a Component Class

With the Mapbox API key in place, let's generate a new component for our map.

$ ember generate component map --with-component-class
installing component
  create app/components/map.js
  create app/components/map.hbs
installing component-test
  create tests/integration/components/map-test.js

Since not every component will necessarily have some defined behavior associated with it, the component generator does not generate a JavaScript file for us by default. As we saw earlier, we can always use the component-class generator to add a JavaScript file for a component later on.

However, in the case of our <Map> component, we are pretty sure that we are going to need a JavaScript file for some behavior that we have yet to define! To save a step later, we can pass the --with-component-class flag to the component generator so that we have everything we need from the get-go.

Zoey says...

Too much typing? Use ember g component map -gc instead. The -gc flag stands for Glimmer component, but you may also remember it as generate class.

Parameterizing Components with Arguments

Let's start with our JavaScript file:

import Component from '@glimmer/component';
import ENV from 'super-rentals/config/environment';

export default class MapComponent extends Component {
  get token() {
    return encodeURIComponent(ENV.MAPBOX_ACCESS_TOKEN);
  }
}

Here, we import the access token from the config file and return it from a token getter. This allows us to access our token as this.token both inside the MapComponent class body, as well as the component's template. It is also important to URL-encode the token, just in case it contains any special characters that are not URL-safe.

Interpolating Values in Templates

Now, let's move from the JavaScript file to the template:

{{yield}}
<div class="map">
  <img
    alt="Map image at coordinates {{@lat}},{{@lng}}"
    ...attributes
    src="https://api.mapbox.com/styles/v1/mapbox/streets-v11/static/{{@lng}},{{@lat}},{{@zoom}}/{{@width}}x{{@height}}@2x?access_token={{this.token}}"
    width={{@width}} height={{@height}}
  >
</div>

First, we have a container element for styling purposes.

Then we have an <img> tag to request and render the static map image from Mapbox.

Our template contains several values that don't yet exist—@lat, @lng, @zoom, @width, and @height. These are arguments to the <Map> component that we will supply when invoking it.

By parameterizing our component using arguments, we made a reusable component that can be invoked from different parts of the app and customized to meet the needs for those specific contexts. We have already seen this in action when using the <LinkTo> component earlier; we had to specify a @route argument so that it knew what page to navigate to.

We supplied a reasonable default value for the alt attribute based on the values of the @lat and @lng arguments. You may notice that we are directly interpolating values into the alt attribute's value. Ember will automatically concatenate these interpolated values into a final string value for us, including doing any necessary HTML-escaping.

Overriding HTML Attributes in ...attributes

Next, we used ...attributes to allow the invoker to further customize the <img> tag, such as passing extra attributes such as class, as well as overriding our default alt attribute with a more specific or human-friendly one.

The ordering is important here! Ember applies the attributes in the order that they appear. By assigning the default alt attribute first (before ...attributes is applied), we are explicitly providing the invoker the option to provide a more tailored alt attribute according to their use case.

Since the passed-in alt attribute (if any exists) will appear after ours, it will override the value we specified. On the other hand, it is important that we assign src, width, and height after ...attributes, so that they don't get accidentally overwritten by the invoker.

The src attribute interpolates all the required parameters into the URL format for Mapbox's static map image API, including the URL-escaped access token from this.token.

Finally, since we are using the @2x "retina" image, we should specify the width and height attributes. Otherwise, the <img> will be rendered at twice the size than what we expected!

We just added a lot of behavior into a single component, so let's write some tests! In particular, we should make sure to have some test coverage for the overriding-HTML-attributes behavior we discussed above.

import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { render, find } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import ENV from 'super-rentals/config/environment';

module('Integration | Component | map', function(hooks) {
  setupRenderingTest(hooks);

  test('it renders', async function(assert) {
    // Set any properties with this.set('myProperty', 'value');
    // Handle any actions with this.set('myAction', function(val) { ... });
  test('it renders a map image for the specified parameters', async function(assert) {
    await render(hbs`<Map
      @lat="37.7797"
      @lng="-122.4184"
      @zoom="10"
      @width="150"
      @height="120"
    />`);

    await render(hbs`<Map />`);
    assert.dom('.map').exists();
    assert.dom('.map img').hasAttribute('alt', 'Map image at coordinates 37.7797,-122.4184');
    assert.dom('.map img').hasAttribute('src', /^https:\/\/api\.mapbox\.com/, 'the src starts with "https://api.mapbox.com"');
    assert.dom('.map img').hasAttribute('width', '150');
    assert.dom('.map img').hasAttribute('height', '120');

    assert.equal(this.element.textContent.trim(), '');
    let { src } = find('.map img');
    let token = encodeURIComponent(ENV.MAPBOX_ACCESS_TOKEN);

    // Template block usage:
    await render(hbs`
      <Map>
        template block text
      </Map>
    `);
    assert.ok(src.includes('-122.4184,37.7797,10'), 'the src should include the lng,lat,zoom parameter');
    assert.ok(src.includes('150x120@2x'), 'the src should include the width,height and @2x parameter');
    assert.ok(src.includes(`access_token=${token}`), 'the src should include the escaped access token');
  });

  test('the default alt attribute can be overridden', async function(assert) {
    await render(hbs`<Map
      @lat="37.7797"
      @lng="-122.4184"
      @zoom="10"
      @width="150"
      @height="120"
      alt="A map of San Francisco"
    />`);

    assert.dom('.map img').hasAttribute('alt', 'A map of San Francisco');
  });

  test('the src, width and height attributes cannot be overridden', async function(assert) {
    await render(hbs`<Map
      @lat="37.7797"
      @lng="-122.4184"
      @zoom="10"
      @width="150"
      @height="120"
      src="/assets/images/teaching-tomster.png"
      width="200"
      height="300"
    />`);

    assert.equal(this.element.textContent.trim(), 'template block text');
    assert.dom('.map img').hasAttribute('src', /^https:\/\/api\.mapbox\.com/, 'the src starts with "https://api.mapbox.com"');
    assert.dom('.map img').hasAttribute('width', '150');
    assert.dom('.map img').hasAttribute('height', '120');
  });
});

Note that the hasAttribute test helper from qunit-dom supports using regular expressions. We used this feature to confirm that the src attribute starts with https://api.mapbox.com/, as opposed to requiring it to be an exact match against a string. This allows us to be reasonably confident that the code is working correctly, without being overly-detailed in our tests.

Fingers crossed... Let's run our tests.

Tests passing with the new <Map> tests

Hey, all the tests passed! But does that mean it actually works in practice? Let's find out by invoking the <Map> component from the <Rental> component's template:

<article class="rental">
  <Rental::Image
    src="https://upload.wikimedia.org/wikipedia/commons/c/cb/Crane_estate_(5).jpg"
    alt="A picture of Grand Old Mansion"
  />
  <div class="details">
    <h3>Grand Old Mansion</h3>
    <div class="detail owner">
      <span>Owner:</span> Veruca Salt
    </div>
    <div class="detail type">
      <span>Type:</span> Standalone
    </div>
    <div class="detail location">
      <span>Location:</span> San Francisco
    </div>
    <div class="detail bedrooms">
      <span>Number of bedrooms:</span> 15
    </div>
  </div>
  <Map
    @lat="37.7749"
    @lng="-122.4194"
    @zoom="9"
    @width="150"
    @height="150"
    alt="A map of Grand Old Mansion"
  />
</article>

Hey! That's a map!

Three Grand Old Mansions

Zoey says...

If the map image failed to load, make sure you have the correct MAPBOX_ACCESS_TOKEN set in config/environment.js. Don't forget to restart the development and test servers after editing your config file!

For good measure, we will also add an assertion to the <Rental> tests to make sure we rendered the <Map> component successfully.

import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';

module('Integration | Component | rental', function(hooks) {
  setupRenderingTest(hooks);

  test('it renders information about a rental property', async function(assert) {
    await render(hbs`<Rental />`);

    assert.dom('article').hasClass('rental');
    assert.dom('article h3').hasText('Grand Old Mansion');
    assert.dom('article .detail.owner').includesText('Veruca Salt');
    assert.dom('article .detail.type').includesText('Standalone');
    assert.dom('article .detail.location').includesText('San Francisco');
    assert.dom('article .detail.bedrooms').includesText('15');
    assert.dom('article .image').exists();
    assert.dom('article .map').exists();
  });
});

Refactoring with Getters and Auto-track

At this point, a big part of our <Map> template is devoted to the <img> tag's src attribute, which is getting pretty long. One alternative is to move this computation into the JavaScript class instead.

From within our JavaScript class, we have access to our component's arguments using the this.args.* API. Using that, we can move the URL logic from the template into a new getter.

Zoey says...

this.args is an API provided by the Glimmer component superclass. You may come across other component superclasses, such as "classic" components in legacy codebases, that provide different APIs for accessing component arguments from JavaScript code.

import Component from '@glimmer/component';
import ENV from 'super-rentals/config/environment';

const MAPBOX_API = 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/static';

export default class MapComponent extends Component {
  get src() {
    let { lng, lat, width, height, zoom } = this.args;

    let coordinates = `${lng},${lat},${zoom}`;
    let dimensions  = `${width}x${height}`;
    let accessToken = `access_token=${this.token}`;

    return `${MAPBOX_API}/${coordinates}/${dimensions}@2x?${accessToken}`;
  }

  get token() {
    return encodeURIComponent(ENV.MAPBOX_ACCESS_TOKEN);
  }
}
<div class="map">
  <img
    alt="Map image at coordinates {{@lat}},{{@lng}}"
    ...attributes
    src="https://api.mapbox.com/styles/v1/mapbox/streets-v11/static/{{@lng}},{{@lat}},{{@zoom}}/{{@width}}x{{@height}}@2x?access_token={{this.token}}"
    src={{this.src}}
    width={{@width}} height={{@height}}
  >
</div>

Much nicer! And all of our tests still pass!

Tests passing after the src getter refactor

Note that we did not mark our getter as @tracked. Unlike instance variables, getters cannot be "assigned" a new value directly, so it does not make sense for Ember to monitor them for changes.

That being said, the values produced by getters can certainly change. In our case, the value produced by our src getter depends on the values of lat, lng, width, height and zoom from this.args. Whenever these dependencies get updated, we would expect {{this.src}} from our template to be updated accordingly.

Ember does this by automatically tracking any variables that were accessed while computing a getter's value. As long as the dependencies themselves are marked as @tracked, Ember knows exactly when to invalidate and re-render any templates that may potentially contain any "stale" and outdated getter values. This feature is also known as auto-track. All arguments that can be accessed from this.args (in other words, this.args.*) are implicitly marked as @tracked by the Glimmer component superclass. Since we inherited from that superclass, everything Just Works™.

Getting JavaScript Values into the Test Context

Just to be sure, we can add a test for this behavior:

import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, find } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import ENV from 'super-rentals/config/environment';

module('Integration | Component | map', function(hooks) {
  setupRenderingTest(hooks);

  test('it renders a map image for the specified parameters', async function(assert) {
    await render(hbs`<Map
      @lat="37.7797"
      @lng="-122.4184"
      @zoom="10"
      @width="150"
      @height="120"
    />`);

    assert.dom('.map').exists();
    assert.dom('.map img').hasAttribute('alt', 'Map image at coordinates 37.7797,-122.4184');
    assert.dom('.map img').hasAttribute('src', /^https:\/\/api\.mapbox\.com/, 'the src starts with "https://api.mapbox.com"');
    assert.dom('.map img').hasAttribute('width', '150');
    assert.dom('.map img').hasAttribute('height', '120');

    let { src } = find('.map img');
    let token = encodeURIComponent(ENV.MAPBOX_ACCESS_TOKEN);

    assert.ok(src.includes('-122.4184,37.7797,10'), 'the src should include the lng,lat,zoom parameter');
    assert.ok(src.includes('150x120@2x'), 'the src should include the width,height and @2x parameter');
    assert.ok(src.includes(`access_token=${token}`), 'the src should include the escaped access token');
  });

  test('it updates the `src` attribute when the arguments change', async function(assert) {
    this.setProperties({
      lat: 37.7749,
      lng: -122.4194,
      zoom: 10,
      width: 150,
      height: 120,
    });

    await render(hbs`<Map
      @lat={{this.lat}}
      @lng={{this.lng}}
      @zoom={{this.zoom}}
      @width={{this.width}}
      @height={{this.height}}
    />`);

    let img = find('.map img');

    assert.ok(img.src.includes('-122.4194,37.7749,10'), 'the src should include the lng,lat,zoom parameter');
    assert.ok(img.src.includes('150x120@2x'), 'the src should include the width,height and @2x parameter');

    this.setProperties({
      width: 300,
      height: 200,
      zoom: 12,
    });

    assert.ok(img.src.includes('-122.4194,37.7749,12'), 'the src should include the lng,lat,zoom parameter');
    assert.ok(img.src.includes('300x200@2x'), 'the src should include the width,height and @2x parameter');

    this.setProperties({
      lat: 47.6062,
      lng: -122.3321,
    });

    assert.ok(img.src.includes('-122.3321,47.6062,12'), 'the src should include the lng,lat,zoom parameter');
    assert.ok(img.src.includes('300x200@2x'), 'the src should include the width,height and @2x parameter');
  });

  test('the default alt attribute can be overridden', async function(assert) {
    await render(hbs`<Map
      @lat="37.7797"
      @lng="-122.4184"
      @zoom="10"
      @width="150"
      @height="120"
      alt="A map of San Francisco"
    />`);

    assert.dom('.map img').hasAttribute('alt', 'A map of San Francisco');
  });

  test('the src, width and height attributes cannot be overridden', async function(assert) {
    await render(hbs`<Map
      @lat="37.7797"
      @lng="-122.4184"
      @zoom="10"
      @width="150"
      @height="120"
      src="/assets/images/teaching-tomster.png"
      width="200"
      height="300"
    />`);

    assert.dom('.map img').hasAttribute('src', /^https:\/\/api\.mapbox\.com/, 'the src starts with "https://api.mapbox.com"');
    assert.dom('.map img').hasAttribute('width', '150');
    assert.dom('.map img').hasAttribute('height', '120');
  });
});

Using the special this.setProperties testing API, we can pass arbitrary values into our component.

Note that the value of this here does not refer to the component instance. We are not directly accessing or modifying the component's internal states (that would be extremely rude!).

Instead, this refers to a special test context object, which we have access to inside the render helper. This provides a "bridge" for us to pass dynamic values, in the form of arguments, into our invocation of the component. This allows us to update these values as needed from the test function.

With all our tests passing, we are ready to move on!

All our tests are passing