GatsbyJS + Drupal: Create Content Type Landing PagesjflynnSun, 12/01/2019 - 16:30

At NEDCamp I had the pleasure of seeing many wonderful people and sessions, but, as you may know, my favorite track at most events is the "Hallway Track". If you're not familiar, the Hallway Track is the time in-between sessions where some real magic happens. You have a chance to talk with some of the most amazing minds in the world about topics you're passionate about. You can share knowledge, bounce ideas, have epiphanies, and realize that your current problems (code-wise) are things that other people have run into. One such conversation happened that inspired me to think outside the box to solve a problem.

In my last post we went over how to create a related content section with references to entities that may have empty fields. Today, we're going to take that one step further and create landing pages for content types.

In Drupal, we would ordinarily build these by attaching a view to a content type based on contextual filters or similar in order to get a collection of all content of that content type. Since we don't have Views in GatsbyJS, we need another solution. There are a couple of options out there, including the recently released JSON:API Cross Bundles module, compliments of Centarro and Matt Glaman. However, at the time of this writing there is an issue with the JSON:API Cross Bundles conflicting with JSON:API Extras. So, if you're relying on JSON:API Extras, you'll need another solution.

The problem:

Out of the box, JSON:API does not create a route to retrieve all nodes with any ease. However, there's no need to fear. This post is here!

Now if you're not using JSON:API Extras, I strongly recommend looking into JSON:API Cross Bundles. It creates a route to all content and will simplify your life. If you are using JSON:API Extras, have I got something for you.

Let's dive in.

Scenario:

You're building a decoupled Drupal site and you want to have a reusable template for landing pages that display all content of a content type. This is easy enough to do in Drupal using Views, but we lose Views when going decoupled so we need another way. How do we accomplish this in a decoupled application using GatsbyJS as the front-end?

Solution:

Strap in folks, this one gets a little bumpy.

Drupal side:

We need to do some work on both sides of the application for this to work. First, we will setup a content type in Drupal to use for our Content Landing Pages. This is kind of a choose your own adventure scenario, but one thing that you absolutely must have is an Entity Reference field that references the Config Entity: Content Type with a cardinality of 1. 

Select this field type:

Dropdown select with Reference: Other selected

 

 And this is the entity type to referenc

Dropdown select with Configuration: Content Type selected

 

Now that we have our field created, select any content type that will require a content landing page as an available option.

Whatever else you want to put on this page is up to you. Go wild. Want a hero image? Add a hero image. Want a dancing baby gif? That's your choice and I respect it. Once you've finished making the greatest landing page content type ever™ we can move on to the fun part in GatsbyJS.

GatsbyJS side:

JSON:API gives us something that can help us out a bit. It goes by the name allNodeTypeNodeType and it will let us workaround the lack of a base all-content route. If we explore this in GraphiQL we'll see that we can drill down a bit and get exactly what we need.

{
  allNodeTypeNodeType {
    nodes {
      relationships {
        node__article
        node__landing_page
        node__basic_page
      }
    }
  }
}

NOTE: I didn't drill down too far, but all fields are available here.

Let's first create our landing page template in Gatsby.

First, let's just create a simple, empty component for our Landing Page with a basic graphql query.

// src/components/templates/LandingPage/index.js
import React from 'react'
import { graphql } from 'gatsby'

function LandingPage({ data }) {
  return (
    
) } export default LandingPage export const query = graphql` query($LandingPageID: String!){ nodeLandingPage(id: { eq: $LandingPageID }) { id title } } `

Nothing too fancy here, right? Just a template that we can use to build our pages out without Gatsby yelling at us on build.

Next, we're going to add this template to gatsby-node.js so that we create our pages dynamically.

// gatsby-node.js

exports.createPages = async ({ graphql, actions }) => {
  const { createPage } = actions

  const LandingPageTemplate = require.resolve(`./src/components/templates/LandingPage/index.js`)

  const LandingPage = await graphql(`{
    allNodeLandingPage {
      nodes {
        id
        path {
          alias
        }
      }
    }
  }
  `)

  LandingPage.data.allNodeLandingPage.nodes.map(node => {
    createPage({
      path: node.path.alias
      component: LandingPageTemplate,
      context: {
        LandingPageID: node.id,
      }
    })
  })
}

This is pretty straightforward so far, right? Let's think about what we're going to need in order for this to work the way we want it to and pull all of a single content type into our landing page.

We're going to need:

  • The content type we want to build a landing page for.
  • A query to fetch all content a content type.
  • The landing page template with logic to display the content type.
  • Probably some other things, but we'll sort that out along the way. We're in this together, remember?

How are we going to get these things? Let's go down the list.

The content type we want to build a landing page from:

We have this from our Drupal side. Remember, we created the Content Type field on our Landing Page content type? This can be placed in our gatsby-node.js and passed to our query via the context option.

Let's add it in.  First we need to update our graphql query to pull it in:

// gatsby-node.js
  const LandingPage = await graphql(`{
    allNodeLandingPage {
      nodes {
        id
        relationships { // ---- add from this line
          field_content_type {
            drupal_internal__type
            name
          }
        } // ---- to this line
      }
    }
  }
  `)

What we're doing here is looking at GraphiQL and exploring our data to see what we have available. If we drill down into allNodeLandingPage.nodes we can see that in relationships we have field_content_type with some useful things. Specifically, our drupal_internal__type and name values. Also, notice that we removed nodes.path.alias from the query.

By adding these to our query we can now pass the info through to our created pages. We're going to do a bit of data manipulation here to create our paths dynamically as well. I follow the convention that a landing page's path should reflect the content type that it's a landing page for. So, if we were making a landing page for "Articles" the path would be path-to-my.site/articles and articles would have that as a base path to path-to-my.site/articles/my-awesome-article. However, you can follow whatever convention you see fit.

To do this, we're going to manipulate the name from the content type into a URL-friendly string by using the JavaScript .replace() function and then pass that to the path option. Since we also want to query for the content type on our landing page, we're going to pass the drupal_internal__type through the context option.

Let's do that:

// gatsby-node.js
  LandingPage.data.allNodeLandingPage.nodes.map(node => {
    const pathName = node.relationships.field_content_type.name.toLowerCase().replace(/ /g, '-') // ---- New line
    createPage({
      path: pathName, // --- Changed line
      component: LandingPageTemplate,
      context: { 
        LandingPageID: node.id,
        ContentType: node.relationships.field_content_type.drupal_internal__type, // ---- New line
      }
    })
  })

What does the the context option do? It passes data to our component as props. GraphQL already pulls the context data for the queries, which you can see in any query that has a variable for the filter. Usually this is the content ID so that it can build a page for a specific piece of content from Drupal, but we can leverage this to add more variables and more filtering however we see fit.

Our next step is going to be to actually USE this additional info to do something amazing with.

A query to fetch all content a content type:

Let's look back at our src/components/templates/LandingPage/index.js and see what we need to query. We know we want to get all nodes of a certain content type, and we know that we want to reuse this template for any landing pages with content listing. Since we've established that allNodeTypeNodeType gives us access to all content on available to Gatsby, let's query on that.

// src/components/templates/LandingPage/index.js

export const query = graphql`
  query($LandingPageID: String!, $ContentType: String!){
    nodeLandingPage(id: { eq: $LandingPageID }) {
      id
      title
    }
    allNodeTypeNodeType(filter: { drupal_internal__type: { eq: $ContentType }}) { // ---- New section
      nodes {
        relationships {
          node__article {
            id
            title
            path {
              alias
            }
          }
          node__page {
            id
            title
            path {
              alias
            }
          }
        }
      }
    }
  }
`

What we're doing here is using that variable we passed via the context option in gatsby-node.js and filtering to only return the content type we're wanting to see. One 'gotcha' here is that this query will also return the landing page that references the content type. However, if you're not creating a landing page of landing pages then you should be alright.

Since we're only creating landing pages for two content types, this is fine, although we're not getting a lot back. Most projects that I've worked on have had some kind of "teaser" display for these kinds of pages. I'm not going to cover the specifics of creating a teaser template here, but the TL;DR is: start with your full display and take out everything but what you want on the teaser. For this post, we're going to create the list of links using the titles.

Now, if the content types that we're creating landing pages for don't have any content, then you're going to have a bad time. In this case, go back to my previous post about empty entity reference fields and see if you can use that to create some default fields and prevent errors or just create some content of the missing type.

Next, let's flesh out our landing page template a bit.

The landing page template with logic to display the content type:

So far, our template, minus the query, is pretty empty and not doing a lot. Let's add in the title of this landing page.

// src/components/templates/LandingPage/index.js
function LandingPage({ data }) {

  const landingPage = data.nodeLandingPage

  return (
    

{landingPage.title}

) }

I like to clean up the variables a bit and rename data.nodeLandingPage to landingPage. It's a bit cleaner to me, but do what you want.

Alright, we have the title of this content, but what about the list of content we want to show on this page? Well, we're going to need to do some logic for that. First off, we need to know which content type we're looking for. Second, we need a way find it. Third, we need to clean this data into something usable. Finally, we need to display it.

We could just display everything returned from our allNodeTypeNodeType query, but there would be a lot of nulls and issues parsing the arrays. Here's an example of what that query returns before we massage the data, using the Drupal internal type article:

{
  "data": {
    "allNodeTypeNodeType": {
      "nodes": [
        {
          "drupal_internal__type": "article",
          "relationships": {
            "node__article": [
              {
                "id": "0e68ac03-8ff2-54c1-9747-3082a565bba6",
                "title": "Article Template",
                "path": {
                  "alias": "/article/article-template"
                }
              }
            ],
            "node__basic_page": null
          }
        }
      ]
    }
  }
}

Now, to get the content this way we could do some complex mapping and sorting and filtering, but I tried that and it wasn't fun. Fortunately, Gatsby is here to rescue us and make life easier. Our context option gets passed into our page component as props. If you're unfamiliar with the concept of Props in React, and therefore Gatsby, props are properties that are passed into components. The line 

function LandingPage({ data }) {

could be rewritten as

function LandingPage(props) {
  const data = props.data

but we're using a concept called Destructuring to only pass in the prop that we need. This allows us to create variables from object keys without having to take the extra steps. Our page component props object also contains the key pageContext which is where anything in the context option gets stored to give the page template access to.

Let's bring that in:

// src/components/templates/LandingPage/index.js
function LandingPage({ data, pageContext }) {

  const landingPage = data.nodeLandingPage
  const nodeType = data.allNodeTypeNodeType
  const contentType = 'node__' + pageContext.ContentType

Since we set our ContentType in gatsby-node.js we're able to use that here. Note that we're concatenating the string node__ with our pageContext.ContentType. We're doing this because everything in Gatsby is a node, including content types. This allows us to do the next steps.

Next, we want to clear out all of the non-content type data from the allNodeTypeNodeType query. This is what it looks like if we were to console.log(nodeType.nodes):

Array(1)
  0:
    relationships:
      node__article: Array(1)
        0: {id: "0e68ac03-8ff2-54c1-9747-3082a565bba6", title: "Article Template", path: {…}, …}
        length: 1
        __proto__: Array(0)
      node__page: null

We only want the node__article array, so how do we get that? Well, we need to use .map() and a concept called currying. This is essentially creating a function that allows us to use a variable from outside of the .map() scope inside of the .map() callback. It allows us to break down a function into more functions so that we have more control over it, which is what we need here.

// src/components/templates/LandingPage/index.js
function LandingPage({ data, pageContext }) {

  const landingPage = data.nodeLandingPage
  const nodeType = data.allNodeTypeNodeType
  const contentType = 'node__' + pageContext.ContentType

  const getContentArray = (contentType) => { // ---- Curry function, but not as delicious
    return (node) => (node.relationships[contentType])
  }

  const contentArray = nodeType.nodes.map(getContentArray(contentType))

We created our curry function that takes our contentType as an argument. From within there, it completes the mapping and returns our desired array... almost.

Here's what we get back if we console.log(contentArray):

[Array(1)]
  0: Array(1)
    0: {id: "0e68ac03-8ff2-54c1-9747-3082a565bba6", id: 1, title: "Article Template", …}
    length: 1
    __proto__: Array(0)
  length: 1
  __proto__: Array(0)

We're almost there, but now we have an array of our content within another array. If only there were a function to help us out here...

Just kidding, there is! For this, we're going to use .flat(). The .flat() function flattens out a nested array into a single level. However, there's a gotcha with it, as mentioned in this Stack/Overflow question. We can get around this by using the gatsby-plugin-polyfill-io plugin.

Add gatsby-plugin-polyfill-io to your project by installing with yarn or npm

npm install gatsby-plugin-polyfill-io
// or
yarn add gatsby-plugin-polyfill-io

and in your gatsby-config.js file add the following within module.exports = {

plugins: [
   {
      resolve: `gatsby-plugin-polyfill-io`,
      options: {
         features: [`Array.prototype.map`, `Array.prototype.flat`]
      },
   },
]

This will also create polyfills for the .map() function, which I use heavily.

So, let's flatten that array!

function LandingPage({ data, pageContext }) {

  const landingPage = data.nodeLandingPage
  const nodeType = data.allNodeTypeNodeType
  const contentType = 'node__' + pageContext.ContentType

  const getContentArray = (contentType) => {
    return (node) => (node.relationships[contentType])
  }

  const contentArray = nodeType.nodes.map(getContentArray(contentType))
  const contentArrayFlat = contentArray.flat()

And the resulting console.log(contentArrayFlat):

0:
  id: "0e68ac03-8ff2-54c1-9747-3082a565bba6"
  path: {alias: "/article/article-template"}
  title: "Article Template"
  length: 1
__proto__: Array(0)

Now we've got exactly what we wanted! The final step is to put this to work. We'll do that by creating a list of titles that link to the content. Your finished component should look like:

// src/components/templates/LandingPage/index.js
import React from 'react'
import { graphql, Link } from 'gatsby' // --- added 'Link' here to use the link component

function LandingPage({ data, pageContext }) {

  const landingPage = data.nodeLandingPage
  const nodeType = data.allNodeTypeNodeType
  const contentType = 'node__' + pageContext.ContentType

  const getContentArray = (contentType) => {
    return (node) => (node.relationships[contentType])
  }

  const contentArray = nodeType.nodes.map(getContentArray(contentType))
  const contentArrayFlat = contentArray.flat()

  return (
    

{landingPage.title}

    // One-liner to create the list of items. {contentArrayFlat.map((item, i) =>
  • {item.title}
  • )}
) } export default LandingPage export const query = graphql` query($LandingPageID: String!, $ContentType: String!){ nodeLandingPage(id: { eq: $LandingPageID }) { id title } allNodeTypeNodeType(filter: { drupal_internal__type: { eq: $ContentType }}) { nodes { relationships { node__article { id title path { alias } } node__page { id title path { alias } } } } } } `

And that's all there is to it. Hopefully you find this useful and it helps speed up your development with Gatsby a little bit.  If I missed anything on here, please don't hesitate to let me know in the comments. Always feel free to reach out to me on Twitter or Slack or any way you want to.

Credit where credit is due: Shane Thomas (AKA @codekarate) and Brian Perry (AKA @bricomedy) helped me work through this issue at NEDCamp.

Patron thanks:

Thank you to my Patreon Supporters

  • David Needham
  • Tara King
  • Lullabot

For helping make this post. If you'd like to help support me on Patreon, check out my page https://www.patreon.com/jddoesthings

Category
Comments
Generic