Setting default tag per category in a theme component

If you’re visiting /tags/category-slug/tag-name and click the New Topic button, the composer has the tag pre-set, as described here:

This is awesome. But now I (and at least one other person) want to be able to set this behavior with a default tag when visiting /c/cat-slug/cat-id. It seems like a theme component should be able to target that button and either modify it, or hide it and add a new button (there’s a plugin outlet right there that I can’t find now, but saw a minute ago).

Can someone give me a hint?

1 Like

Is it supposed to only work on one particular category, or would you need it to support a “default tag” for many categories where that tag is different for each of them?

I imagine that I’d make a setting to have a default tag for a few categories. I probably can do that, but don’t know where/how to change the create topic button to have it include the default tag.

TL;DR skip to working code here


When you’re viewing a page that has the + New Topic, you can check the HTML via the inspector.

If you do that, you’ll notice that it has an id.

Ids on HTML elements are supposed to be unique, as in… no two elements in the same view can share an HTML id attribute. So, this is enough to get us started.

If I search "create-topic" on Github, here’s what I see…

Search · "create-topic" · GitHub

Note the filter on the left.

github search filter

I know that I want to track down the HTML for the button so, handlebars, because I’m trying to track down the action it sends.

So, I select handlebars; then I see this.

Search · "create-topic" · GitHub

There’s only one result in there, so we’re lucky. If there are more results, there are things you can do to narrow the list further, but that’s out of the scope for this topic.

So, let’s check that file.

You will then see that the action that the button has is set like so

action=action

Well… that’s not very helpful… So, what now?

When you see action=action it means that the action is being passed to the component from a parent template.

Let’s try to see which templates have that component. So, we go to Github and search for the component name like it would be used in a template. For this example, we would use something like this "{{create-topic-button"

Not that I only added {{COMPONENT_NAME and skipped the rest. We don’t know the other arguments passed to it, so we want a generic search.

Here’s the result

Search · "{{create-topic-button" · GitHub

We get two results… one of them is in the styleguide plugin, so we simply ignore it. The other is in core. So, let’s see what that looks like

discourse/d-navigation.hbs at 292412f19610d49944f3e109aa7546ccd0553d6a · discourse/discourse · GitHub

  {{create-topic-button
    canCreateTopic=canCreateTopic
    action=(action "clickCreateTopicButton")
    disabled=createTopicButtonDisabled
    label=createTopicLabel
    btnClass=createTopicClass
    canCreateTopicOnTag=canCreateTopicOnTag
  }}

Ahh… we’re getting closer. Now you see that the action for the button is

action=(action "clickCreateTopicButton")

Now we need to find out what that action does. So, we search for the name of the action. Then filter down to .js files because we now want to see the definition of that action in the component’s js file.

Search · "clickCreateTopicButton" · GitHub

Again, we get only one result, so let’s look at it.

So, it looks like the action does one of two things. If the category is read-only and the user doesn’t already have a draft, it shows an alert. Otherwise, it calls a createTopic() method.

We’re interested in the latter, so let’s look at that.

If you search for createTopic() in that file (inline search, not Github)… you’ll notice that there’s only one reference for it. What gives? How is this component calling a method that’s not defined?

Well, the answer is higher up in the file.

What does this mean?

I don’t want to spend a lot of time here, but Ember uses Classes. Think of classes like reusable code bundles. All the line highlighted above means is:

Take the Ember Component bundle, add the FilterModeMixin bundle to it and let me add some more methods, or override some of the existing ones, to the result to create a new Ember component for my application.

So, now let’s go back to the action that we’re trying to track.

clickCreateTopicButton() {
  if (this.categoryReadOnlyBanner && !this.hasDraft) {
    bootbox.alert(this.categoryReadOnlyBanner);
  } else {
    this.createTopic();
  }
},

It calls this.createTopic(). this is not a default Ember component method. It’s a custom Discourse method, so it has to come from FilterModeMixin What is FilterModeMixin? Well…it’s defined at the top of the file.

import FilterModeMixin from "discourse/mixins/filter-mode";

So, I guess we have to go there.

discourse/filter-mode.js at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

Pause for a second and do an inline search for createTopic() in that file. I mean it. Stop reading and do it. I’ll wait… don’t cheat… I have my :eyes: on you.


OK. You searched, and there were no results. What now?

What I described above is only one method for passing things down. If you don’t find what you’re looking for. Take a step back and try a different approach.

So, let’s recap… where are we right now? Before we got stuck, we were looking at the JS file for the d-navigation component. Let’s look at its template.

Again, we use "{{COMPONENT_NAME" and search.

Search · "{{d-navigation" · GitHub

This gives us four results…

Does this matter? Maybe. Does it matter for this case? No. We’re just trying to figure out where createTopic() comes from or what it is. So, let’s just go with the first result.

discourse/default.hbs at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

Would you look at that…

createTopic=(route-action "createTopic")

Great…more jargon… because everyone loves that

Seriously though, let’s talk about route actions. What are they? Well. They’re route…actions? As in actions defined on the route. Why are they nice? Because routes in Discourse can be nested

Look at it this way

- route-1
  - route-1-1
  - route-1-2
  - route-1-3

If I have a shared component that I need to work on routes 111, 112, and 113 with different parameters, wouldn’t it be easier if I just defined use the same component in all of them and pass the same action? Then modify it for each route if needed?

That’s what route-actions do.

OK, let’s get back to the question. We were looking at

createTopic=(route-action "createTopic")

in the navigation/default component.

Now we just have to figure out what the route is to check what that route action does.

You want to modify the behavior of the new topic button in the /c/cat-slug/cat-id pages. So, let’s visit one of those pages. For example: http://localhost:4200/c/meta/6

What is this route? Unless you’re really familiar with Discourse, you wouldn’t be able to tell. So, what now?

This is where the ember extension for your browser becomes handy.

Install it here if you don’t already have it. I’ll wait.
(the link is a Github repository, but the description has the extension links for different browsers)


OK, now you have it installed, visit that page again /c/cat-slug/cat-id and look at the extension page.

Once that’s loaded, click on Routes, then toggle "Current Route only`

Ahhh… look at that. We now know what route we’re on. We’re on discovery.category

But that’s not the whole story… it’s

application > discovery > discovery.category

Remember, routes are nested. So, what now?

I usually start at the very top. In this case, it would be the application route. Find the file for that route and search to see if the action is defined there.

discourse/application.js at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

It turns out that it isn’t… so we move down the nesting tree to the discovery route.

discourse/discovery.js at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

Search there…and… bingo!

Ok, so now we know what

createTopic=(route-action "createTopic")

refers to. So let’s look at that action.

createTopic() {
  if (this.get("currentUser.has_topic_draft")) {
    this.openTopicDraft();
  } else {
    this.openComposer(this.controllerFor("discovery/topics"));
  }
},

It looks like it’s doing one of two things. If the user has a draft, it opens it. If not, it calls openComposer() with a parameter. What’s next? We’ll you should already know the answer by now. We need to find out where openComposer() comes from or what it does.

So we search the file for openComposer() and… of course we don’t get any results. There’s no method in that route called openComposer()

What next? Remember the bit about Ember Classes? Let’s try that.

We have this at the top of the route file.

This means that this route inherits all the methods from the DiscourseRoute “bundle” as well as those defined in the OpenComposer “bundle”

The openComposer is more likely to be what we want, so let’s look at that. Before we do that though… we need to look at how openComposer is defined in that file.

import OpenComposer from "discourse/mixins/open-composer";

Look at the URL. It’s not an Ember component. It’s not a route; it’s not a model. It’s a mixin. What the hell is a mixin? The really, really short answer… it’s a bundle of reusable functions.

You define these in your mixin.

add(number) {
  return number + 1
}

substract(number) {
  return number - 1
}

then add the mixin to your Ember component, then you can do something like this

// starting value is 1
myMethod () {
  this.add(value) // returns 2
  this.substract(value) // returns 0 
}

So, how does this relate to what we’re trying to do?

Well, open-composer here.

import OpenComposer from "discourse/mixins/open-composer";

is a mixin. One of the methods in that mixin is OpenComposer()

It’s okay if you feel confused about this. They share the same name - except that one starts with a capital letter, which indicates that it’s a Class.

They mean different things.

To understand this, you would need to understand that the name you give to your imported modules don’t matter (in this particular case), as long as they’re exported as “default”

Explaining this is a bit beyond the scope of this topic. All you need to know is that this.

OpenComposer here

discourse/discovery.js at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

and openComposer() here

discourse/open-composer.js at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

are not the same thing.

OK… let’s recap.

New topic button HTML id < New topic button action < d-navigation component action < discovery-route action < OpenComposer mixin < openComposer() method

So… this is the method that eventually gets called when you click the + New Topic button on that route.

openComposer(controller) {
  let categoryId = controller.get("category.id");
  if (
    categoryId &&
    controller.category.isUncategorizedCategory &&
    !this.siteSettings.allow_uncategorized_topics
  ) {
    categoryId = null;
  }
  this.controllerFor("composer").open({
    prioritizedCategoryId: categoryId,
    topicCategoryId: categoryId,
    action: Composer.CREATE_TOPIC,
    draftKey: controller.get("model.draft_key") || Composer.NEW_TOPIC_KEY,
    draftSequence: controller.get("model.draft_sequence") || 0,
  });
},

So let’s go back to your question.

We’ve established how you can figure out the action for that button on the /c/cat-slug/cat-id, but it seems different from what happens when you visit /tags/category-slug/tag-name, which is what you want to do.

So what’s the next step? Let’s look at what that route does to handle the createTopic() action.

Well… you’ll notice that it handles the action differently.

for /c/cat-slug/cat-id it looks like this

createTopic() {
  if (this.get("currentUser.has_topic_draft")) {
    this.openTopicDraft();
  } else {
    this.openComposer(this.controllerFor("discovery/topics"));
  }
},

for /tags/category-slug/tag-name it looks like this

createTopic() {
  if (this.get("currentUser.has_topic_draft")) {
    this.openTopicDraft();
  } else {
    const controller = this.controllerFor("tag.show");
    const composerController = this.controllerFor("composer");
    composerController
      .open({
        categoryId: controller.get("category.id"),
        action: Composer.CREATE_TOPIC,
        draftKey: Composer.NEW_TOPIC_KEY
      })
      .then(() => {
        // Pre-fill the tags input field
        if (composerController.canEditTags && controller.get("model.id")) {
          const composerModel = this.controllerFor("composer").get("model");
          composerModel.set(
            "tags",
            [
              controller.get("model.id"),
              ...makeArray(controller.additionalTags)
            ].filter(Boolean)
          );
        }
      });
  }
}

This difference is pretty much what you’re asking for here.

So, all you have to do is… modify the createTopic() action in the discovery route to make it work like it does on the tag-show route. So how do you do that?

Remember how we talked about Ember using Classes? Yeah, we’re going to have to go back to that again.

The plugin API allows you to modify Ember classes via this method.

discourse/plugin-api.js at main · discourse/discourse · GitHub

So, what are we trying to modify here? The discovery route… because… remember, that’s where the createTopic() is defined when you’re on a page like /c/cat-slug/cat-id

We start with this

api.modifyClass("route:discovery", {
  pluginId: "prefill-composer-tags",
  actions: {
    createTopic() {
      console.log("fires");
    }
  }
});

What does that do? It breaks the + New Topic button; however, it tells us that we’re in the right direction. If you try adding the snippet above, you’ll notice that clicking the button no longer opens the composer. Instead, it just prints a message to the console. This is a good thing because it means we’ve targeted the right Class and the right action - route:discovery and createTopic()

So, what’s next? Well, remember that the button on /tags/category-slug/tag-name does exactly what we want. So, let’s copy the code from that route - and add the required imports.

const Composer = require("discourse/models/composer");
const { makeArray } = require("discourse-common/lib/helpers");
api.modifyClass("route:discovery", {
  pluginId: "prefill-composer-tags",
  actions: {
    createTopic() {
      if (this.get("currentUser.has_topic_draft")) {
        this.openTopicDraft();
      } else {
        const controller = this.controllerFor("tag.show");
        const composerController = this.controllerFor("composer");
        composerController
          .open({
            categoryId: controller.get("category.id"),
            action: Composer.CREATE_TOPIC,
            draftKey: Composer.NEW_TOPIC_KEY
          })
          .then(() => {
            // Pre-fill the tags input field
            if (composerController.canEditTags && controller.get("model.id")) {
              const composerModel = this.controllerFor("composer").get("model");
              composerModel.set(
                "tags",
                [
                  controller.get("model.id"),
                  ...makeArray(controller.additionalTags)
                ].filter(Boolean)
              );
            }
          });
      }
    }
  }
});

Will that work? No, but we’re one step close. Why doesn’t it work? Because the tags it adds when the composer opens are not defined. Why? Because they’re loaded from the tag.show controller - which is not what we want. Let’s modify the code to make it work with the route that we’re on.

Before we do that, though, we need some sort of index for our desired default tags. Let’s go with a new object like so

// category-slug: [DEFAULT_TAGS_ARRAY]
const defaultTagIndex = {
  // single word slug
  meta: ["a", "b", "c"],
  core: ["g", "h"],
  // slug with a dash
  ["general-chat"]: ["d", "e", "f"]
};

This basically means if the composer is opened on the meta category page, add tags “a,b,c”
If the composer is opened on the core category page, add tags “g,h” and so on.

Now that we have that, we can modify the action to make it look like this.

Final code

const Composer = require("discourse/models/composer");
const { makeArray } = require("discourse-common/lib/helpers");

// category-slug: [DEFAULT_TAGS_ARRAY]
const defaultTagIndex = {
  // single word slug
  meta: ["a", "b", "c"],
  core: ["g", "h"],
  // slug with a dash
  ["general-chat"]: ["d", "e", "f"]
};

api.modifyClass("route:discovery", {
  pluginId: "prefill-composer-tags",
  actions: {
    createTopic() {
      try {
        const hasDraft = this.currentUser?.has_topic_draft;
        if (hasDraft) {
          this._super(...arguments);
          return;
        } else {
          const controller = this.controllerFor("discovery/topics");
          const composerController = this.controllerFor("composer");
          const categoryId = controller.category?.id;
          const categorySlug = controller.category?.slug;

          if (!categoryId) {
            this._super(...arguments);
            return;
          }

          composerController
            .open({
              categoryId: categoryId,
              action: Composer.CREATE_TOPIC,
              draftKey: Composer.NEW_TOPIC_KEY
            })
            .then(() => {
              // Pre-fill the tags input field
              if (composerController.canEditTags && categoryId) {
                const composerModel = composerController.model;
                composerModel.set(
                  "tags",
                  makeArray(defaultTagIndex[categorySlug]).filter(Boolean)
                );
              }
            });
        }
      } catch {
        this._super(...arguments);
        return;
      }
    }
  }
});

Notes:

  1. I wrapped everything in a try…catch block. If the code fails, we execute this._super(...arguments)

  2. If you’re familiar with Ember, you would know what this._super(...arguments) does. If not, here’s a simple explanation. We’re overriding the createTopic() so if the overrides fail due to an error - maybe core got updated - then fallback to the method in core as defined here

  3. if the user has a new topic draft, we just fall back to this._super(...arguments) and let core do its thing.

That should be enough. All you need to add now is a way to create the default tag index via theme settings.

3 Likes