在主题组件中为类别设置默认标签

如果您访问 /tags/category-slug/tag-name 并点击 <kbd>新建主题</kbd> 按钮,作曲器将预设为该标签,具体说明如下:

这太棒了。但现在我(以及至少另一位用户)希望能够在访问 /c/cat-slug/cat-id 时,通过默认标签来设置此行为。似乎主题组件应该能够针对该按钮,对其进行修改,或者隐藏它并添加一个新按钮(就在那里有一个插件出口,我刚才看到过,但现在找不到了)。

有人能给个提示吗?

它是应该仅针对某一特定类别工作,还是需要支持为多个类别设置“默认标签”,且每个类别的标签各不相同?

我设想可以设置一个默认标签用于几个类别。我大概能做到这一点,但不知道在哪里或如何修改“创建主题”按钮,使其包含默认标签。

TL;DR 直接跳到可运行的代码 这里


当你查看包含 + 新建主题 的页面时,可以通过检查器查看其 HTML。

如果你这样做,会注意到它有一个 id。

HTML 元素上的 id 应该是唯一的,也就是说……同一视图中的两个元素不能共享同一个 HTML id 属性。因此,这足以让我们开始。

如果我在 Github 上搜索 "create-topic",会看到以下内容…

Repository search results · GitHub

注意左侧的过滤器。

我知道我想追踪该按钮的 HTML,因为我在尝试追踪它触发的操作。由于 Handlebars 模板常用于此类场景,所以我选择 Handlebars;然后我看到了这个。

Repository search results · GitHub

那里只有一个结果,所以我们很幸运。如果有更多结果,你可以采取一些措施进一步缩小列表范围,但这超出了本主题的范围。

那么,让我们检查该文件。

然后你会看到按钮的操作设置如下:

action=action

嗯……这不太有帮助……那现在怎么办?

当你看到 action=action 时,意味着该操作是从父模板传递给组件的。

让我们试着找出哪些模板包含该组件。因此,我们前往 Github 并按模板中的使用方式搜索组件名称。对于这个例子,我们会使用类似 "{{create-topic-button" 的内容。

注意,我只添加了 {{COMPONENT_NAME 而跳过了其余部分。我们不知道传递给它的其他参数,所以我们需要一个通用搜索。

结果如下:

Repository search results · GitHub

我们得到两个结果……其中一个在 styleguide 插件中,所以我们直接忽略它。另一个在核心代码中。那么,让我们看看它是什么样子的:

discourse/app/assets/javascripts/discourse/app/templates/components/d-navigation.hbs at 292412f19610d49944f3e109aa7546ccd0553d6a · discourse/discourse · GitHub

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

啊……我们越来越接近了。现在你看到按钮的操作是:

action=(action "clickCreateTopicButton")

现在我们需要弄清楚该操作做了什么。因此,我们搜索该操作的名称。然后过滤到 .js 文件,因为我们现在想在该组件的 js 文件中查看该操作的定义。

Repository search results · GitHub

同样,我们只得到一个结果,所以让我们看看它。

看起来该操作执行以下两种操作之一:如果类别是只读的且用户还没有草稿,则显示警报。否则,它调用 createTopic() 方法。

我们对后者感兴趣,所以让我们看看它。

如果你在该文件中搜索 createTopic()(使用文件内搜索,而不是 Github)……你会注意到只有一个引用。怎么回事?这个组件是如何调用一个未定义的方法的?

好吧,答案在文件的更上方。

这是什么意思?

我不想在这里花太多时间,但 Ember 使用类。可以把类想象成可重用的代码包。上面高亮的那一行意味着:

获取 Ember 的 Component 包,将 FilterModeMixin 包添加到其中,并允许我添加更多方法或覆盖一些现有方法,从而为我的应用程序创建一个新的 Ember 组件。

那么,现在让我们回到我们要追踪的操作。

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

它调用了 this.createTopic()。这不是默认的 Ember 组件方法。它是 Discourse 的自定义方法,所以它必须来自 FilterModeMixin。什么是 FilterModeMixin?嗯……它在文件顶部定义。

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

所以,我想我们得去那里看看。

discourse/app/assets/javascripts/discourse/app/mixins/filter-mode.js at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

暂停一下,在该文件中进行 createTopic() 的 inline 搜索。我是认真的。停止阅读并去做。我会等……别作弊……我盯着你呢:eyes。


好的。你搜索了,但没有结果。现在怎么办?

我上面描述的只是传递内容的一种方法。如果你找不到想要的东西,退一步,尝试不同的方法。

那么,让我们回顾一下……我们现在在哪里?在我们卡住之前,我们正在查看 d-navigation 组件的 JS 文件。让我们看看它的模板。

同样,我们使用 "{{COMPONENT_NAME" 进行搜索。

Repository search results · GitHub

这给了我们四个结果……

这重要吗?也许吧。在这个案例中重要吗?不重要。我们只是想弄清楚 createTopic() 来自哪里或它是什么。所以,我们就用第一个结果吧。

discourse/app/assets/javascripts/discourse/app/templates/navigation/default.hbs at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

看看这个……

createTopic=(route-action "createTopic")

太好了……更多的术语……因为大家都喜欢这个

说真的,让我们谈谈路由操作。它们是什么?嗯,它们是路由……操作?即在路由上定义的操作。为什么它们很好?因为 Discourse 中的路由可以嵌套。

这样看:

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

如果我有一个共享组件,需要在路由 111、112 和 113 上工作,并且需要不同的参数,如果我只在所有路由中使用同一个组件并传递相同的操作,然后在每个路由中根据需要修改它,难道不是更容易吗?

这就是路由操作的作用。

好的,让我们回到问题。我们当时正在查看 navigation/default 组件中的:

createTopic=(route-action "createTopic")

现在我们需要找出路由是什么,以检查该路由操作做了什么。

你想修改 /c/cat-slug/cat-id 页面上的新建主题按钮的行为。所以,让我们访问其中一个页面。例如:http://localhost:4200/c/meta/6

这是什么路由?除非你对 Discourse 非常熟悉,否则你可能无法判断。那么,现在怎么办?

这时,你浏览器的 Ember 扩展就派上用场了。

在这里安装(如果你还没有的话)。我会等。
(链接是一个 Github 仓库,但描述中包含不同浏览器的扩展链接)


好的,现在你安装了它,再次访问该页面 /c/cat-slug/cat-id 并查看扩展页面。

加载完成后,点击“Routes”,然后切换“Current Route only”

啊哈……看看这个。我们现在知道自己在哪个路由上了。我们在 discovery.category 上。

但这还不是全部……它是:

application > discovery > discovery.category

记住,路由是嵌套的。那么,现在怎么办?

我通常从最顶层开始。在这个例子中,就是 application 路由。找到该路由的文件,并搜索看操作是否在那里定义。

discourse/app/assets/javascripts/discourse/app/routes/application.js at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

结果发现不是……所以我们向下移动到嵌套树中的 discovery 路由。

discourse/app/assets/javascripts/discourse/app/routes/discovery.js at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

在那里搜索……然后…… bingo!

好的,现在我们知道:

createTopic=(route-action "createTopic")

指的是什么。所以让我们看看那个操作。

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

看起来它执行以下两种操作之一:如果用户有草稿,则打开它。否则,它调用带有参数的 openComposer()。接下来是什么?嗯,你现在应该已经知道答案了。我们需要找出 openComposer() 来自哪里或它做了什么。

所以我们在文件中搜索 openComposer() 并且……当然我们没有得到任何结果。该路由中没有名为 openComposer() 的方法。

接下来是什么?还记得关于 Ember 类的那部分吗?让我们试试那个。

我们在路由文件的顶部有:

这意味着该路由继承了 DiscourseRoute “包”中的所有方法,以及 OpenComposer “包”中定义的方法。

openComposer 更可能是我们想要的,所以让我们看看它。不过在此之前……我们需要看看 openComposer 在该文件中是如何定义的。

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

看看 URL。它不是 Ember 组件。它不是路由;也不是模型。它是一个 mixin。什么是 mixin?非常非常简短的回答……它是一组可重用的函数。

你在 mixin 中定义这些:

add(number) {
  return number + 1
}

substract(number) {
  return number - 1
}

然后将 mixin 添加到你的 Ember 组件中,然后你可以这样做:

// 起始值为 1
myMethod () {
  this.add(value) // 返回 2
  this.substract(value) // 返回 0 
}

那么,这与我们要做的有什么关系?

嗯,这里的 open-composer

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

是一个 mixin。该 mixin 中的一个方法是 OpenComposer()

如果你对此感到困惑也没关系。它们有相同的名字——除了一个以大写字母开头,这表明它是一个类。

它们的意思不同。

要理解这一点,你需要知道(在这种情况下),你给导入模块起的名字并不重要,只要它们作为“default”导出即可。

解释这一点超出了本主题的范围。你只需要知道:

这里的 OpenComposer

discourse/app/assets/javascripts/discourse/app/routes/discovery.js at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

和这里的 openComposer()

discourse/app/assets/javascripts/discourse/app/mixins/open-composer.js at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

不是同一个东西。

好的……让我们回顾一下。

新建主题按钮 HTML id < 新建主题按钮操作 < d-navigation 组件操作 < discovery 路由操作 < OpenComposer mixin < openComposer() 方法

所以……这就是你在该路由上点击 + 新建主题 按钮时最终被调用的方法。

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,
  });
},

那么,让我们回到你的问题。

我们已经确定了如何在 /c/cat-slug/cat-id 上找出该按钮的操作,但这似乎与你访问 /tags/category-slug/tag-name 时发生的情况不同,而这正是你想做的。

那么下一步是什么?让我们看看该路由如何处理 createTopic() 操作。

嗯……你会注意到它处理该操作的方式不同。

对于 /c/cat-slug/cat-id,它看起来像这样:

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

对于 /tags/category-slug/tag-name,它看起来像这样:

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(() => {
        // 预填充标签输入框
        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)
          );
        }
      });
  }
}

这个差异基本上就是你在这里询问的内容。

所以,你所要做的就是……修改 discovery 路由中的 createTopic() 操作,使其像 tag-show 路由中的那样工作。那么该怎么做呢?

还记得我们说过 Ember 使用类吗?是的,我们得再次回到那里。

插件 API 允许你通过此方法修改 Ember 类。

https://github.com/discourse/discourse/blob/main/app/assets/javascripts/discourse/app/lib/plugin-api.js#L166-L195

那么,我们要修改的是什么?discovery 路由……因为……记住,当你在像 /c/cat-slug/cat-id 这样的页面上时,createTopic() 就在那里定义。

我们从这里开始:

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

这做了什么?它破坏了 + 新建主题 按钮;但它告诉我们我们走在正确的方向上。如果你尝试添加上面的代码片段,你会注意到点击按钮不再打开编辑器,而只是向控制台打印一条消息。这是一件好事,因为我们已经瞄准了正确的类和正确的操作——route:discoverycreateTopic()

那么,接下来是什么?嗯,还记得 /tags/category-slug/tag-name 上的按钮正好做了我们想要的事情吗?所以,让我们从该路由复制代码——并添加所需的导入。

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(() => {
            // 预填充标签输入框
            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)
              );
            }
          });
      }
    }
  }
});

这会起作用吗?不会,但我们已经接近一步了。为什么不起作用?因为编辑器打开时添加的标签未定义。为什么?因为它们是从 tag.show 控制器加载的——这不是我们想要的。让我们修改代码,使其与我们所在的路由一起工作。

不过,在此之前,我们需要某种索引来存储我们想要的默认标签。让我们使用一个新对象,如下所示:

// category-slug: [DEFAULT_TAGS_ARRAY]
const defaultTagIndex = {
  // 单个单词的 slug
  meta: ["a", "b", "c"],
  core: ["g", "h"],
  // 带连字符的 slug
  ["general-chat"]: ["d", "e", "f"]
};

这基本上意味着:如果编辑器在 meta 类别页面上打开,则添加标签 “a,b,c”。
如果编辑器在 core 类别页面上打开,则添加标签 “g,h”,以此类推。

现在有了这个,我们可以修改操作,使其看起来像这样。

最终代码

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

// category-slug: [DEFAULT_TAGS_ARRAY]
const defaultTagIndex = {
  // 单个单词的 slug
  meta: ["a", "b", "c"],
  core: ["g", "h"],
  // 带连字符的 slug
  ["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(() => {
              // 预填充标签输入框
              if (composerController.canEditTags && categoryId) {
                const composerModel = composerController.model;
                composerModel.set(
                  "tags",
                  makeArray(defaultTagIndex[categorySlug]).filter(Boolean)
                );
              }
            });
        }
      } catch {
        this._super(...arguments);
        return;
      }
    }
  }
});

注意:

  1. 我将所有内容包裹在 try…catch 块中。如果代码失败,我们执行 this._super(...arguments)

  2. 如果你熟悉 Ember,你就会知道 this._super(...arguments) 的作用。如果不熟悉,这里有一个简单的解释。我们正在覆盖 createTopic(),所以如果覆盖因错误而失败——也许核心代码已更新——则回退到核心中定义的方法,见 此处

  3. 如果用户有新主题草稿,我们只需回退到 this._super(...arguments) 并让核心代码按原样处理。

这就足够了。你现在需要添加的只是通过主题设置创建默认标签索引的方法。