Skip to content

Instantly share code, notes, and snippets.

@erikzen
Last active September 21, 2020 00:13
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save erikzen/636bd0861259dee433363f8082160029 to your computer and use it in GitHub Desktop.
Save erikzen/636bd0861259dee433363f8082160029 to your computer and use it in GitHub Desktop.
Modular HC Handlebars Theming

Hello Aleksey,

We’d like to provide open source modular theming examples accessible on GitHub sometime in the future, but hopefully this will answer your question in the short term.

1. The Handlebars Partials File

The contents of the community-posts module’s partials.js file are as follows:

const communityPostsListItem = `
<div class="community-posts-list-item">
  <div class="title-container">
    <i class="product-logo">{{{productSVG}}}</i> <a class="title" id="topic" href="{{topic.html_url}}">{{topic.name}}</a> &gt; <a class="title" id="post" href="{{html_url}}">{{title}}</a>
    {{#if status}}
      <span class="status {{status}}">{{statusText}}</span>
    {{/if}}
  </div>
  <div class="user">
    <img class="user-photo" src="{{user.photo.content_url}}"/>
    <span class="name">{{user.name}}</span>
    <span class="date">{{date}}</span>
  </div>
  <div class="details">
    <div class="votes">
      <div class="count">{{vote_count}}</div>
      <div class="text">{{votes}}</div>
    </div>
    <div class="comments">
      <div class="count">{{comment_count}}</div>
      <div class="text">{{comments}}</div>
    </div>
  </div>
</div>
`
Handlebars.registerPartial('communityPostsListItem', communityPostsListItem);

const communityPostsContainerList = `
<div>
  {{#if posts}}
    {{#each posts}}
      {{> communityPostsListItem this}}
    {{/each}}
  {{else}}
    <p class="none">{{none}}</p>
  {{/if}}
</div>
`
Handlebars.registerPartial('communityPostsContainerList', communityPostsContainerList);

const communityPosts = `
<div class="community-posts">
  <h1 class="title">
    {{title}}
  </h1>

  <div class="tabs">
    {{#each tabs}}
      <span class="tab" data-id="{{panel}}">
        <div class="icon">
          {{{icon}}}
        </div>
        <span>{{label}}</span>
      </span>
    {{/each}}
  </div>

  <div class="panels-container">
    <div class="panel topics shadow-blocks__layout">
      {{#each topics}}
        {{> shadowCard this}}
      {{/each}}
    </div>

    <div class="panel posts">
      <div class="filters">
        {{#each filters}}
          <span class="filter {{id}}" data-id="{{id}}">
            {{#if icon}}
              <img class="icon" src="{{icon}}"/>
            {{/if}}
            <span class="name">{{name}}</span>
          </span>
        {{/each}}
      </div>

      <div class="posts-container">
        {{#each filters}}
          <div class="posts {{id}}">
            <img class="loading" src="{{../loadingGif}}" />
          </div>
        {{/each}}
      </div>
    </div>

    <div class="panel eaps shadow-blocks__layout">
      {{#each eaps}}
        {{> shadowCard this}}
      {{/each}}
    </div>
  </div>
</div>
`

Handlebars.registerPartial('communityPosts', communityPosts);

This file is loaded into the module’s index.js file along with other dependencies using ECMA import statements. Webpack resolves the import statements and allows us to build a single asset JS file (modules.js) and CSS file (modules.css) for theme deployment.

The module JS & CSS is loaded and runs like any other Guide theme asset.

Top of index.js:

import './partials'
import './style.scss'

In document_head.hbs:

<script src={{asset 'handlebars-1-0-rc-1.js'}}></script>

... much later in the file ...

<link rel="stylesheet" href="{{asset "modules.css"}}">
<script src="{{asset "modules.js"}}"></script>

Note: we load Handlebars as an asset in document_head.hbs and it’s globally accessible to the module code for legacy purposes.

2. Using the Handlebars Partials

Within index.js, the community-posts constructor compiles the templates and stores references on the instance for use in render methods that run the compiled templates and insert the results into the DOM.

export default class CommunityPosts {
  constructor() {
    const config = window.Modules.communityPosts

    this.state = {
      config,

      /* Compile the templates once on load. */
      communityPostsTemplate: Handlebars.compile('{{> communityPosts}}'),
      communityPostsContainerListTemplate: Handlebars.compile('{{> communityPostsContainerList}}'),

      /* Community Post Filters */
      filters: config.filters,
      posts: {},

      /* jQuery Element References */
      $communityPostRoot: $('#community-posts')
    }

    /* Instance Bindings */
    this.render = this.render.bind(this)
    this.renderPosts = this.renderPosts.bind(this)
    this.fetchPosts = this.fetchPosts.bind(this)
    this.selectorClicked = this.selectorClicked.bind(this)

    /* Initialization */
    if (this.state.$communityPostRoot.length > 0) {
      this.initialize()
    }
  }

  initialize() {
    this.state.filters.map(filter => {
      this.fetchPosts(filter)
        .then(data => {
          const posts = data.posts.map(post => {
            const { postProduct, topic } = parseProductFromTopic(post.topic)
            post.topic = topic
            post.productSVG = productIcons[postProduct]
            return post
          })
          this.state.posts[filter.id] = posts
        })
        .then(() => this.renderPosts(filter))
        .catch(e => console.error(`Could not fetch posts for filter "${filter.id}"!`, e)) //eslint-disable-line
    })

    this.render()
  }

  // ...
  // NOTE: 
  // MANY METHODS OMITTED FOR CLARITY
  // ...

  /* Convenience method to set the contents of a jQuery element. */
  setHTML($element, html) {
    $element[0].innerHTML = html
  }

  renderPosts(filter) {
    const posts = this.state.posts[filter.id]
    const context = {
      none: this.state.config.none,
      posts: (posts && posts.length > 0 ? posts : false)
    }

    /* Render posts panel. */
    const $container = this.state.$communityPostRoot.find(`.panels-container .posts.${filter.id}`)
    this.setHTML($container, this.state.communityPostsContainerListTemplate(context))
  }

  render() {
    this.setHTML(this.state.$communityPostRoot,
      this.state.communityPostsTemplate({
       ...window.Modules.communityPosts
      })
    )

    /* Bindings and Initialization */
    const selectors = ['.tabs .tab', '.filters .filter']
    this.state.$communityPostRoot.find(selectors.join(', ')).click(this.selectorClicked)
    this.state.$communityPostRoot.find(selectors.map(selector => `${selector}:first`).join(', ')).click()
  }
}

This module is instantiated once on every page but will only run the initialize() method and subsequent fetching and render methods to decorate the page if the $communityPostRoot element is found.

3. Data Management

Finally, it should be noted the data used for rendering the module is assembled partially from Zendesk server side rendered Curlybars templating and partially from REST requests to endpoints. This is the content of community-posts.hbs which actually gets appended to the Guide footer.hbs Curlybars template when we build the theme.

Note: we use the {{dc }} helper extensively to load localized strings dynamically. This string data is substituted on the server side for use in browser by the module. Use with caution in your own theme.

<!-- community-posts.hbs -->
<script>
  (function() {
    "use strict";

    const ICONS = window.Modules.GardenIcons
    const PRODUCT = getCurrentProduct();
    const tabs = [
      {
        panel: 'posts',
        icon: ICONS['duplicate-stroke'],
        label: "{{t 'posts'}}"
      }
    ]

    const FEATURED_POSTS = {
      support: "{{dc 'community_posts_featured_posts_support'}}",
      chat: "{{dc 'community_posts_featured_posts_chat'}}",
      explore: "{{dc 'community_posts_featured_posts_explore'}}",
      connect: "{{dc 'community_posts_featured_posts_connect'}}"
    }

    const filters = [
      {
        id: "recent",
        icon: false,
        name: "{{dc 'community_posts_f_recent'}}",
        url: '/api/v2/community/posts.json?per_page=100&sort_by=updated_at&include=users,topics'
      },
      {
        id: "trending",
        icon: false,
        name: "{{dc 'community_posts_f_trending'}}",
        url: '/api/v2/community/posts.json?per_page=100&sort_by=updated_at&include=users,topics'
      },
      {
        id: "featured",
        icon: false,
        name: "{{dc 'community_posts_f_featured'}}",
        url: '/api/v2/community/posts/ID_PLACEHOLDER.json?include=users,topics',
        ids: FEATURED_POSTS[PRODUCT] || FEATURED_POSTS['support']
      },
      {
        id: "unanswered",
        icon: false,
        name: "{{dc 'community_posts_f_unanswered'}}",
        url: '/api/v2/community/posts.json?per_page=100&sort_by=created_at&include=users,topics'
      }
    ]

    /* Parse JSON Configs */
    const jsonConfigs = {
      topics: window.CARD_DATA['topics']['en-us'],
      eaps: window.CARD_DATA['eaps']['en-us']
    }
    Object.values(jsonConfigs).forEach(function(config) {
      config.forEach(function(item) {
        if(item && (item.icon in window.Modules.ProductIcons || item.icon in window.Modules.GardenIcons)) {
          item.icon = window.Modules.ProductIcons[item.icon] || window.Modules.GardenIcons[item.icon]
        }
      })
    })

    if(jsonConfigs.topics.length > 0){
      tabs.unshift({
        panel: 'topics',
        icon: ICONS['speech-bubble-conversation-stroke'],
        label: "{{t 'topics'}}"
      })
    }

    if(jsonConfigs.eaps.length > 0){
      tabs.push({
        panel: 'eaps',
        icon: ICONS['clipboard-list-stroke'],
        label: 'EAPs'
      })
    }

    window.Modules = Object.assign({}, window.Modules || {}, {
      communityPosts: {
        title: "{{dc 'community_posts_title'}}",
        none: "{{dc 'community_posts_none'}}",
        comment: "{{t 'comment' count=1}}",
        comments: "{{t 'comment' count=2}}",
        vote: "{{t 'vote' count=1}}",
        votes: "{{t 'vote' count=2}}",
        statusMap: {
          planned: "{{dc 'community_posts_planned'}}",
          not_planned: "{{dc 'community_posts_not_planned'}}",
          answered: "{{dc 'community_posts_answered'}}",
          completed: "{{dc 'community_posts_completed'}}"
        },
        tabs: tabs,
        filters: filters,
        topics: jsonConfigs.topics,
        eaps: jsonConfigs.eaps,
        loadingGif: "{{asset 'loading.gif'}}"
      }
    })
  })()
</script>
@evenfrost
Copy link

This is awesome and very helpful, thank you for the info!

@kitcat-dev
Copy link

@erikzen Thank you, very much!

I have a question. Is there a way to precompile this on the build stage, not in runtime? I tried to write a custom webpack loader that parses {{> ... }} strings, imports partial's content and replaces expressions with passed parameters. It works but I suppose, it's not an ideal solution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment