Drupal 8, JSONAPI and Ember Part 3: How to Render All The Things

Scroll Down

Written by Justin Langley

August 29, 2017

Drupal 8, JSONAPI and Ember Part 3: How to Render All The Things

We have a JSONAPI backend. We have a front-end application wired up. How do I render all the things now?

Welcome to Part 3!

Time to dive deeper into templating and seeing how Ember Data makes working with data models super easy.

Now let's wire up some more routes!



Nested Routes

Now we should add in some routes for our Blogs to have an overarching Blogs Listing page as well as a route that will render out a single blog post. So let's open up our app/router.js file and add our routes.

You'll notice there isn't much in this file right now, except for where the const Router is being declared and extended off Ember.Router. The Router.map function is where we can define all of the routes of our app as well the matching URL for that route. You can think of it as defining what URLs will trigger which Routes.

Now let's update our Router.map function to look like this:

app/router.js

  import Ember from 'ember';
  import config from './config/environment';

  const Router = Ember.Router.extend({
    location: config.locationType,
    rootURL: config.rootURL
  });

  Router.map(function() {
    this.route('index', { path: '/' });
    this.route('blogs', { path: '/blogs'}, function(){
      this.route('index', { path: '/' });
      this.route('blog', { path: '/:blog_name' });
    });
  });

  export default Router;

The .map function for the Router works like this: 1. The .route method of the Router expects two parameters. 2. The first parameter is the name of the associated file in routes that should be called. 3. The second parameter is an optional options hash. It expects either resetNamespace or path keys. 4. The path key is used to define what URL matches this route, these URLs can define dynamic segments. 5. The resetNamespace key is used to determine if nested child routes should contain the parent's path prepended to the child's path. 6. The third parameter is a function that allows you to define "nested" routes within this route.

Our Router.map function now says this:
"If the URL is / (we are on the home page) then we call the routes/index.js route file. If we are on the URL /blogs then we have two separate routes for that. If we are actually on /blogs then we should call the routes/blogs/index.js file, otherwise if we are on /blogs/{something else} then we should call the routes/blogs/blog.js route file."

The /:blog_id path specifies a dynamic segment that will get passed to the route as a parameter. So a URL path of /blogs/super-cool-blog then our route would receive super-cool-blog as a path parameter that we could use to load a blog.

If none of this made sense, I suggest reading through Ember's guide on Routing here.


Time to create the routes!

From the terminal inside our myapp directory let's create our routes!

ember g route blogs/index

So this will create the routes/blogs directory and create an index.js file inside routes/blogs. Now let's generate the other route.

ember g route blogs/blog

And now we have the route and template for our individual blog pages. So now we should set up our model to contain our fields from the backend to be included on the model.

Open up your models/blog-post.js file and update the following:

app/models/blog-post.js

import DS from 'ember-data';

const {
  attr,
  belongsTo,
  Model,
} = DS;

export default Model.extend({
  title: attr('string'),
  field_blog_content: attr(),
  field_blog_image: attr()
});

You'll notice now we are using ES6 object destructuring assignment to get the attr, belongsTo and Model out of the DS import. You can check out the documentation on Object Destructuring Assignment here.

However, when you load up the site and inspect the Ember application you'll notice that field_blog_image is undefined even though field_blog_content is not. This is because in Drupal 8 Image fields are actually a reference field to Image object (which contain the URI of the image file). So in JSONAPI this would be a relationship. In order for us to get the image, we need to make an Ember model for the image objects. Easiest way to do this is to lookup the properties of an Image object from the JSONAPI endpoint. So using something like Postman will make debugging these API calls far easier.

So using Postman I'm going to hit the URL http://decoupled-drupal.dev/jsonapi/node/blog_post and then I can find the relationships key for the first object, then find field_blog_image and then hit the links:related URL listed there (which in JSONAPI is the URL for that specific resource). From there we will see what the actual image object referenced on our node looks like in the JSONAPI.

You'll notice that the type property says file--file, which tells us that this a file entity! To handle relationships from JSONAPI in our app we need to create a model for this resource and then we'll reference it in our blog-post model. Let's go back to our config/environment.js file and update it to this:

...

...
    drupalEntityModels: {
      "blog-post": {
        entity: "node",
        bundle: "blog_post"
      },
      "product": {
        entity: "node",
        bundle: "product"
      },
      "file": {
        entity: "file",
        bundle: "file",
      }
    }

The file entry is defining a new model that we can use to reference file entities. So now let's add a new model!

ember g model file

And now let's add the attributes from the JSONAPI object to our model. Update the models/file.js file to look like this:

app/models/file.js

import DS from 'ember-data';

const {
  attr,
  Model,
} = DS;

export default Model.extend({
  url: attr('string'),
});

Great! Now let's load up our app and check the Ember Inpsector! Looks like in the Data tab we now see two models blog-post and file, but file has 0 objects! JSONAPI by default only includes relationships if you specifically ask for them. Open up the routes/application.js and update it like so.

app/routes/application.js

import Ember from 'ember';

export default Ember.Route.extend({
  model() {
    return this.get('store').findAll('blog-post', { include: 'field_second_image' });
  }
});

Now we are telling our findAll query to also include the relationship field_second_image with our data. Now if we check the Ember Inspector we will now see that we have data for the file model. And that's how to get related entities from other entities with JSONAPI and Ember.

So let's now remove that model from routes/application.js and move it to our routes/blogs/index.js file.

app/routes/blogs/index.js

// routes/blogs/index.js
import Ember from 'ember';

export default Ember.Route.extend({
  model() {
    return this.get('store').findAll('blog-post', { include: 'field_second_image' });
  }
});

Now let's replace the contents of templates/application.hbs to be:

{{outlet}}

.. and update templates/blogs/index.hbs to be:

{{#each model as |blog|}}
  <h2>The Blog Title is: <em>{{blog.title}}</em></h2>
  <p>The Blog's ID is: <em>{{blog.id}}</em></p>
{{/each}}
{{outlet}}

and finally update templates/index.hbs to be:

{{#link-to "blogs"}}Blogs{{/link-to}}

The link-to is a helper that will render a URL to a specified route. So we want users to be able to hit our Blogs page so we add a link to it! On the front of your app you should now see a single link that says "Blogs" and clicking it will take you to localhost:4200/blogs where you will now see your blog rendered!

The route we defined earlier:

...

  this.route('blogs', { path: '/blogs'}, function(){
    this.route('index', { path: '/' });
    this.route('blog', { path: '/:blog_name' });
  });

shows that when we hit /blogs Ember will try and find the route at routes/blogs/index.js to load up and then look for the templates/blogs/index.js and hook up that Route with that Template. Now let's go to localhost:4200/blogs/cool-blog and you'll see a console error for "No model was found for 'blog'. " This is because we haven't defined the model for our routes/blogs/blog.js file. So let's do that!

app/routes/blogs/blog.js

import Ember from 'ember';

export default Ember.Route.extend({
  model(params) {
    return this.get('store').query('blog-post', { title: params.blog_name });
  }
});

If you define dynamic segments in your route's path, (i.e. { path: '/:blog_name' } ) then those are available inside the parameter passed to our model() hook in the route. Which is why if you console.log(params) inside the model(params) function you will see that it's an object with a key name that corresponds to the param name in our route.

To demonstrate, let's create a blog post on our decoupled site called Cool Blog as the node title and fill out all the other fields. Now if we visit /blogs/cool-blog... we will see nothing.

The problem we have is that our blog titles are not lower cased and hyphenated. We could by default use the entity's id parameter, but those aren't very user-friendly to read URLs. Now this next part I would not recommend for production sites at all, but it's more for demonstration purposes. Update your routes/blogs/blog.js file to look like this:

app/routes/blogs/blog.js

import Ember from 'ember';

export default Ember.Route.extend({
  convertToTitle(blogTitle) {
    return blogTitle.replace(/-/g, ' ').replace(/^./, (x) => x.toUpperCase());
  },

  model(params) {
    return this.get('store').query('blog-post', { title: this.convertToTitle(params.blog_name) });
  }
});

The convertToTitle function is just a small piece to turn the phrase cool-blog into Cool Blog. So now our model hook queries our JSON backend for any blog post with a title of Cool Blog and returns it. Now navigating to /blogs/cool-blog we will see a blog-post loaded in Ember Data!

Time to render! Update our templates/blogs/blog.hbs file to look like:

app/templates/blogs/blog.hbs

{{#each model as |blog|}}
  <div class="blog-title">{{blog.title}}</div>
  <div class="blog-id">{{blog.id}}</div>
  <img height="200" width="200" src="http://decoupled-drupal.dev/{{blog.field_second_image.url}}" />
{{/each}}
{{outlet}}

You'll notice your blog being rendered as well as the image! Huzzah!

So that wraps up Part 3 of this series of blogs on using Drupal 8, JSONAPI and Ember to create decoupled applications. Once you understand the conventions of Ember it becomes extremely easy to spin up new apps and prototypes!

Happy coding!