テーマコンポーネントでカテゴリごとにデフォルトタグを設定する

/tags/category-slug/tag-name にアクセスして <kbd>New Topic</kbd> ボタンをクリックすると、ここでも説明されているように、トピック作成画面にタグが事前に設定されます。

これは素晴らしい機能です。しかし、今では私(そして少なくとも他の一人)が、/c/cat-slug/cat-id にアクセスした際にデフォルトのタグを設定できるようにしたいと考えています。テーマコンポーネントがそのボタンをターゲットにして、修正するか、あるいは隠して新しいボタンを追加できるはずです(今すぐは見つけられませんが、数分前にプラグインアウトレットがあったはずです)。

何かヒントをいただけないでしょうか?

特定の1つのカテゴリでのみ動作するように設計されているのか、それとも各カテゴリで異なる「デフォルトタグ」をサポートする必要があるのでしょうか?

いくつかのカテゴリにデフォルトタグを設定できるオプションを作ろうと考えています。それは多分可能だとは思いますが、「トピック作成」ボタンにそのデフォルトタグを含めるように変更する方法や場所がわかりません。

TL;DR 動作するコードはこちら


+ New Topicボタンが表示されているページを表示している場合、インスペクターを通じて HTML を確認できます。

そうすると、その要素に id が設定されていることに気づくでしょう。

HTML 要素の ID は一意である必要があります。つまり、同じビュー内に同じ HTML id 属性を共有する要素は 2 つ以上存在してはいけません。したがって、これだけで始めることができます。

Github で "create-topic" を検索すると、以下のような結果が表示されます。

Repository search results · GitHub

左側のフィルターに注意してください。

ボタンの HTML を特定したいので、アクションを追跡するために Handlebars を確認します。

したがって、Handlebars を選択すると、以下のような結果が表示されます。

Repository search results · GitHub

結果は 1 つだけなので、幸運です。結果が複数ある場合は、リストをさらに絞り込む方法がありますが、それはこのトピックの範囲外です。

さて、そのファイルを確認してみましょう。

すると、ボタンのアクションが以下のように設定されていることがわかります。

action=action

うーん…あまり役立ちませんね。では、次にどうすればよいでしょうか?

action=action と表示されている場合、そのアクションは親テンプレートからコンポーネントに渡されていることを意味します。

どのテンプレートにそのコンポーネントが含まれているかを確認してみましょう。Github で、テンプレートで使用される形式のコンポーネント名を検索します。この例では、"{{create-topic-button" のように検索します。

{{COMPONENT_NAME 以降は省略しました。他の引数が何であるか分からないため、一般的な検索を行います。

結果は以下の通りです。

Repository search results · GitHub

2 つの結果が返ってきました。そのうち 1 つは styleguide プラグイン内のものなので、無視します。もう 1 つはコア部分にあります。では、それがどのように見えるか見てみましょう。

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

再び結果は 1 つだけなので、それを見てみましょう。

どうやら、このアクションは 2 つの動作のいずれかを行います。カテゴリが読み取り専用で、ユーザーにドラフトが存在しない場合はアラートを表示します。そうでない場合は、createTopic() メソッドを呼び出します。

私たちは後者に関心があるので、それを見てみましょう。

そのファイル内で createTopic() を検索してください(Github 検索ではなく、ファイル内の検索です)。すると、参照は 1 つしかないことに気づくでしょう。どういうことでしょうか?このコンポーネントは、定義されていないメソッドをどのように呼び出しているのでしょうか?

答えは、ファイルのより上部にあります。

これは何を意味するのでしょうか?

ここで時間を費やしたくはありませんが、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() をインライン検索してください。本当にやってみてください。読むのをやめて検索してください。私は待っています…嘘をつかないでください…私の :eyes: はあなたを見ています。


OK。検索しましたが、結果はありませんでした。次にどうすればよいでしょうか?

上記で説明したのは、情報を下方に渡す方法の 1 つに過ぎません。探しているものが見つからない場合は、一歩引いて別のアプローチを試してみてください。

さて、振り返りましょう。今どこにいるのでしょうか?行き詰まる前、私たちは d-navigation コンポーネントの JS ファイルを見ていました。そのテンプレートを見てみましょう。

再び "{{COMPONENT_NAME" を使用して検索します。

Repository search results · GitHub

4 つの結果が返ってきました…

これは重要でしょうか?もしかしたら。このケースでは重要でしょうか?いいえ。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 で機能する必要がある共有コンポーネントがあるとします。すべてのルートで同じコンポーネントを使用し、同じアクションを渡す方が簡単ではありませんか?その後、必要に応じて各ルートでそれを修正します。

それがルートアクションが行うことです。

OK、質問に戻りましょう。私たちは navigation/default コンポーネント内の

createTopic=(route-action "createTopic")

を見ていました。

次に、そのルートアクションが何を行うかを確認するために、ルートが何かを特定する必要があります。

/c/cat-slug/cat-id ページで「新しいトピック」ボタンの動作を変更したいと考えています。なので、そのようなページの 1 つを訪れてみましょう。例えば:http://localhost:4200/c/meta/6

このルートは何でしょうか?Discourse に非常に精通していない限り、判断できないでしょう。では、次にどうすればよいでしょうか?

ここで、ブラウザ用の Ember 拡張機能が役立ちます。

まだ持っていない場合は、こちら からインストールしてください。待っています。
(リンクは Github リポジトリですが、説明には異なるブラウザ用の拡張機能へのリンクがあります)


OK、インストールしたので、そのページ /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

そこで検索すると…ビンゴ!

OK、これで

createTopic=(route-action "createTopic")

が何を指すかが分かりました。そのアクションを見てみましょう。

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

2 つの動作のいずれかを行っているようです。ユーザーにドラフトがある場合は、それを開きます。そうでない場合は、パラメータ付きで openComposer() を呼び出します。次は何でしょうか?答えはもう分かっているはずです。openComposer() がどこから来るか、またはそれが何を行うかを確認する必要があります。

なので、ファイル内で openComposer() を検索すると…もちろん結果は得られません。そのルートに openComposer() という名前のメソッドはありません。

次にどうするか?Ember クラスについての話を思い出してください。それを使ってみましょう。

ルートファイルの上部には以下があります。

これは、このルートが DiscourseRoute 「バンドル」および OpenComposer 「バンドル」で定義されたすべてのメソッドを継承することを意味します。

openComposer の方がおそらく望ましいので、それを見てみましょう。その前に… openComposer がそのファイルでどのように定義されているかを確認する必要があります。

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

URL を見てください。Ember コンポーネントでも、ルートでも、モデルでもありません。ミックスインです。ミックスインって何でしょうか?非常に短い答え…それは再利用可能な関数のバンドルです。

これらをミックスイン内で定義します。

add(number) {
  return number + 1
}

substract(number) {
  return number - 1
}

次に、そのミックスインを Ember コンポーネントに追加すると、以下のようなことができます。

// 初期値は 1
myMethod () {
  this.add(value) // 2 を返す
  this.substract(value) // 0 を返す
}

では、これが私たちが行おうとしていることとどのように関連するのでしょうか?

さて、ここでの open-composer です。

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

はミックスインです。そのミックスインのメソッドの 1 つは OpenComposer() です。

これについて混乱しても構いません。名前は同じですが、1 つは大文字で始まっています。これはクラスであることを示しています。

それらは異なる意味を持ちます。

これを理解するには、インポートするモジュールに付ける名前(この特定のケースでは)は「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

は同じものではありません。

OK…振り返りましょう。

新しいトピックボタンの HTML ID < 新しいトピックボタンのアクション < d-navigation コンポーネントのアクション < discovery ルートのアクション < OpenComposer ミックスイン < openComposer() メソッド

つまり、これが /c/cat-slug/cat-id ルートで + New Topic ボタンをクリックしたときに最終的に呼び出されるメソッドです。

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

これは何を까요?+ New Topic ボタンを壊しますが、正しい方向に進んでいることを教えてくれます。上記のスニペットを追加しようとすると、ボタンをクリックしてもコンポーザーが開かなくなり、代わりにコンソールにメッセージが出力されることに気づくでしょう。これは良いことです。正しいクラスと正しいアクション(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 = {
  // 単一の単語のスラッグ
  meta: ["a", "b", "c"],
  core: ["g", "h"],
  // ダッシュ付きのスラッグ
  ["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 = {
  // 単一の単語のスラッグ
  meta: ["a", "b", "c"],
  core: ["g", "h"],
  // ダッシュ付きのスラッグ
  ["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) にフォールバックし、コアに任せます。

これで十分です。追加する必要があるのは、テーマ設定を通じてデフォルトタグインデックスを作成する方法だけです。