(Superseded) Plugin tutorial #3 - How to add a button after every post?

:information_source: This topic has been superseded by Developing Discourse Plugins - Part 1 - Create a basic plugin

EDIT: Updated the tutorial & fixed the plugin code.


This time we’re going to add a button after every post that will allow users to hide/show the post.

But first, we need to understand how these buttons are generated.

They are defined by the post_menu site setting:

This setting determine which items will appear on the post menu and their order:

This list is used by the PostMenu view which renders the menu below a post using buffered rendering for performance. We’ve already seen the buffer rendering technique in the previous tutorial. It just means that we’re pushing strings into a buffer.

This view has 3 interesting methods:

render: function(buffer) {
  var post = this.get('post');

  buffer.push("<nav class='post-controls'>");
  this.renderReplies(post, buffer);
  this.renderButtons(post, buffer);
  this.renderAdminPopup(post, buffer);
  buffer.push("</nav>");
}

This render method is called by emberjs whenever it needs to render the view. First, it will render the replies button (renderReplies()) if there is any. Then, it will render all the buttons (renderButtons()) and finally, it will render the admin popup (renderAdminPopup()).

Let’s dig into the renderButtons method:

renderButtons: function(post, buffer) {
  // ...
  var yours = post.get('yours');
  Discourse.SiteSettings.post_menu.split("|").forEach(function(i) {
    var creator = self["buttonFor" + i.replace(/\+/, '').capitalize()];
    if (creator) {
      var button = creator.call(self, post);
      if (button) {
        // ...
        visibleButtons.push(button);
      }
    }
  });
  // ...
  buffer.push('<div class="actions">');
  visibleButtons.forEach(function (b) {
    b.render(buffer);
  });
  buffer.push("</div>");
},

I’ve simplified this function a bit for a better comprehension. It basically iterates through the post_menu site setting and calls the methods named buttonFor<Button> if they are defined on the view. For example, for the reply button, it will call the method named buttonForReply. This method should return a Button object that is then added to a list of visibleButtons which are rendered into the view at the end of the method. That Button class is an helper class used to render buttons.

Finally, the 3rd interesting method:

// Delegate click actions
click: function(e) {
  var $target = $(e.target),
      action = $target.data('action') || $target.parent().data('action');

  if (!action) return;
  var handler = this["click" + action.capitalize()];

  if (!handler) return;
  handler.call(this, this.get('post'));
}

This click method is called by emberjs whenever the user clicks on the view (that is, on any button). That method retrieve the name of the action to call by looking at the data-action attribute of the targeted element or its parent’s. If an action is found, it will look for a method name click<Action> in the view and call it if it is defined in the the view. For example, if the action is reply, it will call the clickReply method.

So, this allows us to control both the rendering of our button by defining a buttonForSomething method and the behavior whenever the user clicks on the button by defining a clickSomething method.

Let’s call our button hide and add it to the post_menu site setting:

We’re now all set to start writing our plugin.

Let’s start with our usual suspect, plugin.rb, our entry point:

# name: hide post
# about: add a button at the end of every post allowing users to hide the post
# version: 0.2
# authors: Régis Hanol

Note that there’s nothing to add. We’ll add a .js.es6 which will be automatically loaded by the plugin system.

Let’s create assets/javascripts/initializers/hide-button.js.es6 and add the following code:

import { Button } from "discourse/views/post-menu";

export default {
  name: "hide-posts",

  initialize: function (container) {
    var PostMenuView = container.lookupFactory("view:post-menu");

    PostMenuView.reopen({
      buttonForHide: function (post, buffer) {
        var direction = !!post.getWithDefault("temporarily_hidden", false) ? "down" : "up";
        return new Button("hide", direction, "chevron-" + direction);
      },

      clickHide: function () {
        $("#post_" + this.get("post.post_number") + " .cooked").toggle();
        this.toggleProperty("post.temporarily_hidden");
      }
    });

  }
};

This file uses the ES6 syntax to import and export modules. The first line imports the Button class from the post-menu view so that we can use it in our code. Then, we declare the default module we’re exporting and it’s an simple javascript object.

This object has a name property and an initialize method that takes a container for argument. This is an initializer. It will be called whenever the application boots up.

Inside the initialize method, we use the container object to lookup for the post-menu view and use that reference to reopen the class. This is the Ember way of reopening a class from another file. This allows us to add instance methods and properties that are shared across all instances of that class. Which is exactly what we want here. So, as we’ve seen earlier, we need to define 2 methods: buttonForHide and clickHide.

The buttonForHide method is used to render the hide button which is composed of a chevron icon. It looks for the temporarily_hidden property on post to determine the direction of the chevron.

The clickHide method is toggling the visibility of the current post via jQuery and the temporarily_hidden value.

If you try to run the plugin like so, you will notice that it works but the chevron is not changing direction. It’s because it’s not being rerendered whenever the value of post.temporarily_hidden is changed. To allow for that, we add a new observer using Discourse.View.renderIfChanged which will be observing that property and will internally call rerender() on the view itself.

PostMenuView.reopen({
  shouldRerenderHideButton: Discourse.View.renderIfChanged("post.temporarily_hidden"),

  // rest of the code...
})

And voilà.

Here’s a screenshot with the button on the left (the chevron)

When you click on it, it hides the post and the chevron changes direction

Hope you enjoyed it :smile:

As usual, sources are available on GitHub.

18 Likes

Geez, these plugin tutorials are pretty awesome! Nice work, man!

Change the following line:

buffer.push('<i class="icon-chevron-' + direction + '"></i>');

to…

buffer.push('<i class="fa fa-chevron-' + direction + '"></i>');

and this plugin works great!

2 Likes

Yeah… THANK YOU FONTAWESOME :wink:

EDIT: just updated the first post and the repository

I’ve got a fun, little challenge here. Trying to add a button to the end of each post that allows the creator of a given topic to mark one post as being “correct.” This is similar to what you might find on a site like StackOverflow. I’ve got it working (kinda), but am running into a couple issues. Check out the Github Repo for all the code. And here’s a screenshot of what it looks like:

As you can see, there are “Mark as correct” buttons after the 1st and 3rd posts that the topic owner can click to choose the post that answered his/her question. When that button is clicked, it switches to looking like the highlighted post in the middle. Notice the button has changed to text that reads, “This post was marked as correct.”

Jumping into the code, assets/javascripts/correct_post.js is where I’m having some trouble. Here’s the code from that file:

Discourse.PostMenuView.reopen({
  shouldRerenderCorrectButton: Discourse.View.renderIfChanged("post.correctPostId"),

  renderCorrect: function(post, buffer) {
    var correctPostId = post.getWithDefault("correctPostId", this.get("post.topic.correct_post_id"));
    
    if (correctPostId && this.get("post.id") == correctPostId) {
      buffer.push("<div id='correct-text'>This post was marked as correct</div>");
      var postNumber = this.get("post.post_number");
      setTimeout(function() {
        $("#post_" + postNumber + " .topic-body").addClass("correct-post");
      }, 250);
    } else {
      if (this.get("post.topic.details.can_edit")) {
        buffer.push('<button title="Mark this post as solving your initial question" data-action="correct">Mark as correct</button>');
      }
    }
  },

  clickCorrect: function() {
    this.get('controller').markSolved(this.get("post"));
    $(".correct-post").removeClass("correct-post");
    $("#correct-text").replaceWith('<button title="Mark this post as solving your initial question" data-action="correct">Mark as correct</button>');
    this.set("post.correctPostId", this.get("post.id"));
  }
});

Off the bat, you’ll see a couple things that look extremely ugly:

  1. The use of setTimeout() in renderCorrect to add a class to the correct post.
  2. The use of removeClass() and replaceWith() in clickCorrect to unselect a previously correct post if the topic creator changes his/her mind and chooses a new correct post.

setTimout()

The reason I succumbed to using setTimeout() was due to page loading via HTML versus page rendering via JSON. At first, I figured I could just use the code within the setTimeout() function (but without the setTimeout()). That worked fine if the page was already loaded and all I did was select a correct post. If I then refreshed the page, the correct post did not appear highlighted in green because

$("#post_" + postNumber + " .topic-body")

did not exist when addClass() was called. Thus, I decided to use setTimeout.

removeClass() and replaceWith()

The use of removeClass() and replaceWith() I’m sure is easily resolved with better use of Ember, but I’m pretty new to Ember, so I need some help. Basically, I was hoping clickCorrect could just look like this:

clickCorrect: function() {
  this.get('controller').markSolved(this.get("post"));
  this.set("post.correctPostId", this.get("post.id"));
}

would mean that anytime the topic creator selected a new correct post, "post.correctPostId" would update, and all of the post menu buttons on the page would re-render. Turns out that clicking the “Mark as correct” button for a particular post only triggers the re-rendering of the post menu buttons for that post and not all of the posts on the page.

Again, while the solution above works, (1) it’s clunky and (2) it does have a failure point. If you click the “Mark as Correct” button for a few different posts, it stops working after a while. I believe this is a consequence of using too much jQuery to render new HTML on the page.


Feel free to drop this plugin into your app in local development and play around with it. Would appreciate any help (PRs to the Github Repo preferred!). Thanks!

Here you go :smiley_cat:

3 Likes

This works! Thank you, Regis!! :smile:

1 Like

@zogstrip I have a quick question - if I want to register a plug-in and use an Ember component as the template do I just put it in assets/javascripts/discourse/templates/components and Discourse recognizes when loading it that it is a component by the name (using a dash of course?)

I’ve never tried it but it should work. Have you tried it?

Yep, when registering the asset it doesn’t complain but when the template should be rendering it acts like there is no template that matches. I am using a - in the name and the Ember.Component is getting extended so I am assuming it just isn’t recognizing it as a component. Could definitely be operator error but I can get a view to display no problem so not sure.

@zogstrip could you do a tutorial on how to customize composer toolbar?

That’s unfortunately not an easy thing to do right now :frowning:

However, we do have plans to make it easier in the future.

1 Like

@zogstrip Discourse.PostMenuView.reopen no longer works. Looking through your code, PostMenuView no longer exists and seems to have been replaced with post-menu.js.es6. Given this change, how should we go about adding buttons after every post now? Thanks!

Do this at the start of your .js.es6 file:

import PostMenuView from 'discourse/views/post-menu';

Then use PostMenuView as a global in the code.

Thanks, @riking. Simply adding that line, switching instances of Discourse.PostMenuView to PostMenuView and changing my file to .js.es6 did not solve the problem. Looking at discourse/views/post-menu, it looks like PostMenuView is no longer a thing? And now there’s a buttonForThing function for each button type? It seems like @zogstrip’s post from above should be updated to reflect the way things work now post-ES6 because this plugin tutorial is no longer accurate.

3 Likes

Just updated the tutorial and fixed the code so that it actually works :wink:

6 Likes

Hi, thank you for tutorial. I have a question about it. As i understand some time ago there were changes in Discourse, which brought incompatibility with current source code.

I’ve made some changes, and seems all works fine.

https://github.com/woto/discourse-hide-post/commit/289968daade123813cc1a4588cbcb3849e629898

But when i added another plugin which use same technique then i found problems with event triggering.

https://github.com/woto/Solved-Button/commit/2244245c0517f26c35ed452644f1ccf7ac710ece

Can you suggest better solution to make them work together?

Ok, here is another downside effect. Standard buttons for post also work incorrectly.

I think link of plugin tutorial #2 is broken :frowning:

Yes, now that there have been changes with both posts and the header both going VDOM a lot has become moot,

i.e. how to work with the post menu has changed.
Perhaps the outdated tutorial was accidentally deleted instead of getting a proper send off?

In any case it would likely provide little more than a historical reference at this point.

1 Like