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:
- The
.route
method of the Router expects two parameters. - The first parameter is the name of the associated file in
routes
that should be called. - The second parameter is an optional options hash. It expects either
resetNamespace
orpath
keys. - The
path
key is used to define what URL matches this route, these URLs can define dynamic segments. - The
resetNamespace
key is used to determine if nested child routes should contain the parent's path prepended to the child's path. - 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!
Want to talk about how we can work together?
Ryan can help