Fire CMS as a Single Page Application

In the previous post in this series I explained how to use the Firebase Hosting and the Firestore in order to define a multilingual content hierarchy for a website. Then we rendered this content using lit-html. Now I would like to demonstrate how to organize this into a component model, and then layer on the aspects of a Single Page Application (SPA) onto this scheme.

You will want to be familiar with the code snippets in the previous post as we will now be extending them.

Sample Data

Before we get started, refer to the previous post for setting up sample data in the Firestore. Then extend that as is shown below. We will have three pages, each with a title, description, and path property, along with a content property that will be an array of content which we will use to build the content for each page.
  • /en/us/home
    • title (type: String)
      • "The Home Page"
    • description (type: String)
      • "This is the home page"
    • path (type: String)
      • "/en/us/home"
    • type (type: String)
      • "home"
    • content (type: Array)
      • "A simple home page."
  • /en/us/home/example
    • title (type: String)
      • "Example"
    • description (type: String)
      • "This is an example page"
    • path (type: String)
      • "/en/us/home/example"
    • content (type: Array)
      • "Some sample content"
      • { "type": "header", "text": "A sample header" }
      • { "type": "image", "src": "<some image url>" }
  • /en/us/home/anotherPage
    • title (type: String)
      • "Another Page"
    • description (type: String)
      • "This is another page"
    • path (type: String)
      • "/en/us/home/anotherPage"
    • content (type: Array)
      • { "type": "header", "text": "A sample header" }
      • "First paragraph"
      • "Second paragraph"

Component Model

In the previous post we had a getPath function that allowed us to use the location of the current page in order to get the data associated with that page. We will expand that concept into a Router component and a BasicPage component as shown below. This uses the Vanilla Javascript Component Pattern that I have talked about before, but also uses lit-html for templating.

Router

import {html, render} from '/vendor/lit-html.js';
import BasicPage from '/components/BasicPage.js';

export default class Router {
  init(container) {
    this.container = container;

    this.render();
  }

  render() {
    render(Router.markup(this), this.container);
    this.pageComponent = new BasicPage(
        this.container.querySelector('.page'),
        Router.getPath(window.location.pathname));
  }

  static markup({}) {
    return html`<div class="page"></div>`;
  }

  static getPath(path) {
    const truePath = ['', '/'].includes(path) ? '/us/en/home' : path;
    const parts = truePath.substring(1).split('/')
    let query = db.collection('pages');
    parts.forEach((part, index) => {
      query = query.doc(part);
      if (index !== parts.length -1) query = query.collection('children');
    });
    return query;
  }

  constructor(container) {
    // The constructor should only contain the boiler plate  
    // code for finding or creating the reference.
    if (typeof container.dataset.ref === 'undefined') {
      this.ref = Math.random();
      Router.refs[this.ref] = this;
      container.dataset.ref = this.ref;
      this.init(container);
    } else {
      // If this element has already been instantiated,
      // use the existing reference.
      return Router.refs[container.dataset.ref];
    }
  }
}

Router.refs = {};

document.addEventListener('DOMContentLoaded', () => {
  new Router(document.getElementById('router'))
});

If you are familiar with the Vanilla Javascript Component Pattern, the first thing we have done here is instantiated a sub component of type BasicPage, which we will define later in the post. Additionally we have added a getPath method, which we already defined in the previous post in this series.

BasicPage

import {html, render} from '/vendor/lit-html.js';

export default class BasicPage {
  init(container, pageQuery) {
    this.container = container;
    this.pageQueryValue = pageQuery;
    this.render();
  }

  render() {
    render(BasicPage.markup(this), this.container);
  }

  set pageQuery(page) {
    this.pageQueryValue = page;
    this.render();
  }

  get pageQuery() {
    return this.pageQueryValue;
  }

  static markup({pageQuery}) {
    return html`
      <div class="content">
        ${pageQuery.get().then(page => html`
          <p class="description">${page.data().description}</p>
        `)}
      </div>
      <div class="nav">
        ${pageQuery.get().then(page => html`
          ${page.data().title}
        `)}
      </div>
    `;
  }

  constructor(container, doc) {
    // The constructor should only contain the boiler plate
    // code for finding or creating the reference.
    if (typeof container.dataset.ref === 'undefined') {
      this.ref = Math.random();
      BasicPage.refs[this.ref] = this;
      container.dataset.ref = this.ref;
      this.init(container, doc);
    } else {
      // If this element has already been instantiated,
      // use the existing reference.
      return BasicPage.refs[container.dataset.ref];
    }
  }
}

BasicPage.refs = {};

Here we have another fairly plain class. It defines a pageQuery getter and setter, and initialized the pageQuery in the init method. This is a Firebase promise that will return our page data. If you look back to the router, this pageQuery is created using the getPath method. Having a setter on this value will allow the Router to re-render the page as the user navigates.

Adding Navigation

We created three pages in the Firestore at the beginning of this post, but we currently are not linking between them. Let's add some navigation to our BasicPage component.

export default class BasicPage {
  ...  

  get homePageQuery() {
    return (async () => {
      let pageQuery = this.pageQuery;
      let type = (await pageQuery.get()).data().type;
      while (type != 'home') {        pageQuery = pageQuery.parent.parent;
        type = (await pageQuery.get()).data().type;
      }

      return pageQuery;
    })();
  }

  get navPagesQuery() {
    return this.homePageQuery
    .then(homePageQuery => homePageQuery.collection('children').get())
    .then(children => {
      let firstLevelPages = [];
      children.forEach(child => {
        firstLevelPages.push(child.data());
      });
      return firstLevelPages
    });
  }

  static markup({pageQuery, homePageQuery, navPagesQuery}) {
    return html`
      <div class="content">
        ${pageQuery.get().then(page => html`
          <p class="description">${page.data().description}</p>
        `)}
      </div>
      <div class="nav">
        <div class="current">
          ${pageQuery.get().then(page => html`
            ${page.data().title}
          `)}
        </div>
        <div class="links">
          ${homePageQuery.then(homePageQuery => homePageQuery.get())
                         .then(homePage => html`
            <a href="${homePage.data().path}"
               class="${homePage.data().path === window.location.pathname
                        ? 'active' : ''}">${homePage.data().title}</a>
          `)}
          ${navPagesQuery.then(navPages => navPages.map(navPage => html`
            <a href="${navPage.type === 'redirect'
                       ? navPage.redirect : navPage.path}"
               target="${navPage.type === 'redirect'
                         ? '_blank' : ''}"
               class="${navPage.path === window.location.pathname
                        ? 'active' : ''}">${navPage.title}</a>
          `))}
        </div>
      </div>
    `;
  }

  ...
}

Here we have simply added a homePageQuery getter method which uses the pageQuery and then creates a new query for getting the home page. We also added a navPagesQuery that uses the homePageQuery to get a new query for each page under the home page. Finally, the markup method was updated to utilize these values.

Rendering Page Content

export default class BasicPage {
  ...

  renderComponent(comp) {
    if (typeof comp === 'string') {
      return html`<p>${comp}</p>`;
    } else if (comp.type === 'image') {
      return html`<img src="${comp.src}"></a>`;
    } else if (comp.type === 'header') {
      return html`
        <hr>
        <h3>${comp.text}</h3>
      `;
    }
  }

  static markup({pageQuery, homePageQuery, navPagesQuery, renderComponent}) {
    return html`
      <div class="content">
        ${pageQuery.get().then(page => html`
          <p class="description">${page.data().description}</p>
          <hr>
          ${page.data().content ? page.data().content
            .map(comp => renderComponent(comp)) : ''}
        `)}
      </div>
      <div class="nav">
        <div class="current">
          ${pageQuery.get().then(page => html`
            ${page.data().title}
          `)}
        </div>
        <div class="links">
          ${homePageQuery.then(homePageQuery => homePageQuery.get())
                         .then(homePage => html`
            <a href="${homePage.data().path}"
               class="${homePage.data().path === window.location.pathname
                        ? 'active' : ''}">${homePage.data().title}</a>
          `)}
          ${navPagesQuery.then(navPages => navPages.map(navPage => html`
            <a href="${navPage.type === 'redirect'
                       ? navPage.redirect : navPage.path}"
               target="${navPage.type === 'redirect'
                         ? '_blank' : ''}"
               class="${navPage.path === window.location.pathname
                        ? 'active' : ''}">${navPage.title}</a>
          `))}
        </div>
      </div>
    `;
  }

  ...
}

Here we have simply added a method which takes either a string or an object, and returns a lit-html template depending on the type property. Images return an image tag, headers return an h3 tag, and strings return a paragraph. This method is then provided to the markup method, and the content property of the current page is iterated through and each value is provided to the renderComponent method for rendering.

Single Page Application

Now in order to make this a Single Page Application we will need to capture navigation events, and override the behavior. Instead of the page doing a full refresh, the Router will simply set the pageQuery on our BasicPage component using the getPath method.

export default class Router {
  init(container) {
    this.container = container;

    document.onclick = (e) => {
      e = e ||  window.event;
      var element = e.target || e.srcElement;

      if (element.tagName == 'A' &&
          element.getAttribute('href').startsWith('/')) {
        window.history.pushState({}, null, element.getAttribute('href'));
        this.pageComponent.pageQuery =
            Router.getPath(window.location.pathname);
        return false;
      }
    };

    this.render();
  }
  
  ...
}

Remember that getPath is a static method and so we must refer to it using the class name. Additionally, it returns a promise of page data for the current location. The BasicPage classes set method for this value forces a re-rendering of the view, and so now we should see updated views as we navigate around the site without the need for a full page refresh. Additionally we get deep linking for free in this setup, so urls can point to specific pages, and users won't always be sent to a starting screen as is the case for some SPA's.

Concluding Thoughts

We now have a data model for our Fire CMS, a component model for client side rendering and functionality, and some basic link overriding for implementing it as a Single Page Application so that the user can navigate without page refreshes. Only the data that we need at the moment is sent over the wire. This should lead to a very fast and pleasant user experience. The next step is to extend this concept into a Progressive Web App so that the user can install our website as an app on modern platforms. We will cover this last step in a future blog post.

Comments

Popular Articles

The Vanilla Javascript Component Pattern

The Sunless Citadel: A D&D 5e Session Report

Getting Started with Harp