Add Ember Components to Discourse

In the previous tutorial I showed how to configure both the server and the client side parts of Discourse to respond to a request.

In this tutorial, I’m going to create a new Ember Component as a way to wrap third party Javascript. This is going to be similar to a YouTube video I made a while back, which you may find informative, only this time it’s specific to Discourse and how we lay out files in our project.

Why Components?

Handlebars is quite a simple tempting language. It’s just regular HTML along with some dynamic parts. This is simple to learn and great for productivity, but not so great for code re-use. If you’re developing a large application like Discourse, you’ll find that you want to re-use some of the same things over and over.

Components are Ember’s solution to this problem. Let’s create a component that will display our snack in a nicer way.

Creating a new Component

Components need to have a dash in their name. I’m going to choose fancy-snack as the name for this one. Let’s create our template:

app/assets/javascripts/admin/templates/components/fancy-snack.hbs

<div class='fancy-snack-title'>
  <h1>{{snack.name}}</h1>
</div>

<div class='fancy-snack-description'>
  <p>{{snack.description}}</p>
</div>

Now, to use our component, we will replace our existing admin/snack template with this:

app/assets/javascripts/admin/templates/snack.hbs

{{fancy-snack snack=model}}

We can now re-use our fancy-snack component in any other template, just passing in the model as required.

Adding Custom Javascript Code

Besides re-usability, Components in Ember are great for safely adding custom Javascript, jQuery and other external code. It gives you control of when the component is inserted into the page, and when it is removed. To do this, we define an Ember.Component with some code:

app/assets/javascripts/admin/components/fancy-snack.js.es6

export default Ember.Component.extend({
  didInsertElement() {
    this._super();
    this.$().animate({ backgroundColor: "yellow" }, 2000);
  },

  willDestroyElement() {
    this._super();
    this.$().stop();
  }
});

If you add the above code and refresh the page, you’ll see that our snack has an animation of a slowly fading yellow background.

Let’s explain what’s going on here:

  1. When the component is rendered on the page it will call didInsertElement

  2. The first line of didInsertElement (and willDestroyElement) is this._super() which is necessary because we’re subclassing Ember.Component.

  3. The animation is done using jQuery’s animate function.

  4. Finally, the animation is cancelled in the willDestroyElement hook, which is called when the component is removed from the page.

You might wonder why we care about willDestroyElement at all; the reason is in a long lived Javascript application like Discourse it’s important to clean up after ourselves, lest we leak memory or leave things running. In this case we stop the animation, which tells any jQuery timers that they needn’t fire any more as the component is no longer visible on the page.

Where to go from here

The final tutorial in this series covers automated testing.

17 Likes

Hi, how do i extend a discourse component thru a plugin? Can you give me some points. Thanks

Generally we prefer you don’t extend Discourse plugins, and you stick to plugin outlets or using the widget decoration API to add stuff.

But if you must, you can create an initializer and use Ember’s extend code. Here’s an example that extends an Ember object.

4 Likes

Tried with initializer but didnt worked. What i actually want to do is to add 2 more classNames and some actions:

import { withPluginApi } from 'discourse/lib/plugin-api';

function initializeComponentTopicList(api) {
  // extend component from jsapp/components/topic-list.js.es6
  const TopicList = api.container.lookupFactory('component:topic-list');

  TopicList.extend({
    classNames: ['topic-list', 'round', 'table'],
    actions: {
        clickMe: function() {
            console.log('click');
        }
    }
  });
};

export default {
  name: "extend-for-component-topic-list",

  initialize() {
    withPluginApi('0.1', initializeComponentTopicList);
  }
};

And by “didnt worked”, i mean that topic list completly disappeared from page.
Thank you

Were there any logs in the console?

Nope, no logs at all. However i managed to fix it this way. I hope it will help someone.

import { default as TopicList } from 'discourse/components/topic-list';
import { withPluginApi } from 'discourse/lib/plugin-api';

function initializeComponentTopicList(api) {
  TopicList.reopen({
    classNames: ['topic-list', 'round', 'table'],
  });
};

export default {
  name: "extend-for-component-topic-list",

  initialize() {
    withPluginApi('0.1', initializeComponentTopicList);
  }
};
2 Likes

I just ran into the same problem. Add the following as a css/html customisation and observe empty user cards:

<script type="text/discourse-plugin" version="0.5">
    api.container.lookupFactory('component:user-card-contents')
</script>
2 Likes