モデルにカスタムフィールドを追加する方法

これは、Discourse の異なるモデルにカスタムフィールドを追加する方法を示す教育用プラグインのコレクションです。Discourse プラグインの構築を学びたい方の学習ツールとして提供されています。

GitHub-Mark トピックにカスタムフィールドを追加する方法
GitHub-Mark カテゴリにカスタムフィールドを追加する方法

対象者

これらのプラグインは、Discourse プラグインの作成についてさらに学びたい方を対象としています。これらのプラグインに取り組む前に、Discourse プラグイン作成の初心者向けガイド を完了してください。

これらのプラグインを、Discourse インスタンスにカスタムフィールドを追加する目的で直接使用することも可能ですが、その場合でもコードを少し変更する必要があります。ライブサーバーでの「プラグイン&プレイ」用途には設計されていません。

仕組み

動作するコードを含めるだけでなく、各プラグインには、コードが何を行っているかを段階的に説明するコメントが記載されています。例えば:

## 
# type:        step
# number:      1
# title:       Register the field
# description: Where we tell discourse what kind of field we're adding.
#              You can register a string, integer, boolean or json field.
# references:  lib/plugins/instance.rb,
#              app/models/concerns/has_custom_fields.rb
##
register_topic_custom_field_type(FIELD_NAME, FIELD_TYPE.to_sym)

ステップとコメントは、それ自体で意味がわかるようになっています。references は、さらに詳しく学びたい場合に参照先を示すために用意されています。

役立つと感じた場合、何か動作しない場合、または説明が不明確な場合は、お気軽にお知らせください :slight_smile:

「いいね!」 28

私たちのためにプラグインを構築してくださりありがとうございます。
プラグインをインストールした後に以下のエラーが発生しました。

\u003e Oops
このディスカッションフォーラムを動かしているソフトウェアで予期せぬ問題が発生しました。ご迷惑をおかけして申し訳ございません。
エラーに関する詳細情報はログに記録され、自動的に通知が生成されました。確認いたします。
追加の対応は不要です。ただし、エラー状態が継続する場合は、エラーを再現する手順などを含む追加の詳細を、サイトのフィードバックカテゴリにディスカッショントピックを投稿して提供してください。

こんにちは、ローカル開発環境で実行されていますか?もしそうであれば、開発ログをプライベートメッセージでお送りいただけますか?開発環境が正しく設定されていれば、このプラグインは正常に動作します。

「いいね!」 1

読んでいますが、理解できません。このプラグインのインストールから、ローカル開発環境の構築、そしてカスタムフィールドの追加までをステップバイステップで解説したビデオチュートリアルを作成していただけないでしょうか?
このプラグインへのアクセスが非常に難しいです。
可能であれば、フィールドタイプが「数値」の場合の検索機能を強化していただけませんか?

このプラグインに寄付することもできますので、ぜひサポートをお願いします!

@angus さんがまとめてくれたこのリソースは、私が最初に思っていた以上に役立ちます。

カスタムフィールドを追加するためのコードが明確な解説付きで掲載されているだけでなく、その多くはプラグインにそのまま流用できます。コードの大部分は FIELD_NAMEFIELD_VALUE といった変数を使用しており、これらは plugin/config/settings.yml で定義する必要があるからです(また、GitHub のコード(@angus が提供したもの)と同じプラグインのファイル構造になっていることを確認する必要があります)。コードを読み進めることで、以前から目にしてはいたものの、これまで本当に理解できていなかった Discourse の関数やメソッドについても、理解が深まりました。

現時点では、このコードはトピックのカスタムフィールドの作成と保存において非常にうまく機能しています。ただ、私の中で浮かんできた疑問が 2 つあります:

  1. トピック一覧のエラー:カスタムフィールドを追加する前に作成されたトピックが存在するカテゴリの一覧(つまり、特定カテゴリのトピック一覧)を読み込もうとすると、エラーが発生するようです。例外ページが表示され、以下のエラーがリストされます:
    Attempting to access a non preloaded custom field, this is disallowed to prevent N+1 queries.
    これを解決するための推奨方法はありますか?

  2. 特定の分類のトピックにのみカスタムフィールドを適用することは可能でしょうか?例えば、Category 1、Category 2、Category 3 というカテゴリがあり、カスタムフィールドの入力フォームが表示されるのは Category 3 に属するトピックの場合のみとし、保存されるのもその場合のみとしたいとします。そのような設定は可能でしょうか?

「いいね!」 1

私ども(Pavilion)は今後そのようなものを作成する予定ですが、現時点ではコードと手順のみとなります。特定の課題でつまずいている場合は、Dev に投稿して、その課題について詳しく説明してください。

トピック一覧でカスタムフィールドを使用する際に、フィールドを事前に読み込む方法を示すために、トピック用カスタムフィールドプラグインのトピックにサブステップを追加しました。

表示対象のカテゴリを特定するために、カテゴリ用カスタムフィールドが必要です。このプラグインでは、カテゴリ用カスタムフィールドについても同様の処理を行い、段階的な手順も同様に提供しています。

これらの 2 つの教育用プラグインを組み合わせても、目指すゴールには完全に到達できませんが、そこから先を進めてみてください。

「いいね!」 2

素晴らしいですね、@angus さん。どうもありがとうございます。

動画があると確かに親切ですが、@angus さんがまとめられたこれらのリソースから重要な値を取得するために必須だとは思いません。これらのリソースは、特定の目的(トピックのカスタムフィールドやカテゴリのカスタムフィールドの作成)を達成するために必要なコードを提供しています。動画があれば、おそらく @angus さんや他の誰かがリソースの実装方法について解説することになるでしょうが、それは straightforward(直感的でわかりやすい)なものであり、ここではその手順をまとめて説明できるはずです。

明確にしておくと、これらのリソースは、サイトに追加するだけでフォーラムをカスタマイズする「プラグ・アンド・プレイ」式のプラグインではありません。むしろ、これらはプラグイン内で独自のカスタムフィールドをコーディングするために必要な理解を効率的に得るためのものです。


私がこれらのリソースを利用した方法は以下の通りです:

まず、config/settings に、追加したいフィールドの名前とタイプを追加する必要があります。これらのリソースのコードでは、そこで定義された変数が使用されています。そのため、その後はコードを大幅にカスタマイズする必要はなく、plugin.rb などの場所にある変数が config/settings を参照して動作するようになります。

config/settings を更新した後、以下の手順でコードをプラグインに追加して進めてください:

  • plugin.rb のコードから始め、独自のプラグインの plugin.rb に追加してカスタムフィールドを作成します。

  • 次に、initializer(assets/javascripts/discourse/[custom-field-initializer])のコードを確認し、カスタムフィールドを初期化してサーバーに保存できるようにするコードを取得します。

  • 次に、ユーザー(またはアプリがフィールドを自動的に追加する場合、そのアプリ)がカスタムフィールドの値を入力できるフォームをビュー層に作成します。こちら(assets/discourse/connectors/[plugin-outlet-name]/[your special template].hbs)が該当します。

  • @angus さんは、Discourse テンプレートに挿入されるプラグインアウトレットにカスタムフィールドのフォームを追加するように設定しています。このフォームの設定はこちら(assets/javascripts/discourse/lib/[custom-field-name].js.es6)にあり、フォームを機能させるためにおそらくこれをカスタマイズしたくなるでしょう。

@angus さん、もし私が誤って記述している箇所があれば、ご指摘ください。

上記の手順を踏んでカスタムフィールドの設定に慣れた後、私はさらにカスタマイズを進めました(例えば、フォームの動作をより創造的にするなど)が、これは非常に有益な出発点であり、数時間の作業を節約してくれました。

これらを一通り行った後、いくつか質問が出ましたが(先ほど質問したように)、それ以降は Dev で回答を得るのが最も効果的な方法だと感じています。

「いいね!」 3

素晴らしい説明ですね!はい、それが本来の使い方で正しいです :+1:

「いいね!」 1

編集:カスタムフィールドに基づいてアイテムを取得する方法について、最初はここで質問を投稿しましたが、この質問は十分に異なるため、別の投稿として出すことにしました。そのため、別途こちら に投稿しました。

「いいね!」 2

トピックのカスタムフィールドの例に従って、コンポーザーに奇妙な動作が発生しています。

「トピックを作成」ボタン(例えばカテゴリ表示ページなど、サイト上のどこでも)をクリックすると、コンポーザーが開かず、以下のエラーが表示されます。

Uncaught Error: Assertion Failed: The key provided to set must be a string or number, you passed undefined
    at assert (index.js:172)
    at set (index.js:2802)
    at Class.set (observable.js:176)
    at composer.js:769
    at Array.forEach (<anonymous>)
    at Class.open (composer.js:768)
    at composer.js:898
    at invokeCallback (rsvp.js:493)
    at rsvp.js:558
    at rsvp.js:19

このエラーは、新しいページに「トピックを作成」ボタンを追加しようとした際に初めて発生しましたが、その後、その新しいボタンを削除し、関連するコードをすべて削除してもエラーは解消されません。

どうやら、以下のコード(topic-custom-field-initializer からのもので)が問題を引き起こしているようです。

api.serializeOnCreate(fieldName);
api.serializeToDraft(fieldName);
api.serializeToTopic(fieldName, `topic.${fieldName}`);

このコードを削除すると、「トピックを作成」ボタンは再び正常に動作し(コンポーザーが正しく開く)、コードを戻すとコンポーザーのエラーが再発します。

以前はこのコードをプラグインに含めても問題ありませんでしたが、現在はコンポーザーのエラーを引き起こします(プラグイン内のコンポーザーやトピック作成ボタンに関連するコードをすべて削除しても同様です)。

もちろん、このコードはカスタムフィールドをシリアライズするために重要ですが、コンポーザーと競合しているようです。解決策についてご教示ください。

原因を特定できました。トピックカスタムフィールドのイニシャライザー に、2 つの異なるカスタムフィールドを追加しようとしていたためです。何らかの理由で、これが干渉を引き起こしていました。そのファイルに 2 つのカスタムフィールドを適切に追加する方法はあるかもしれませんが、私のコードでは、2 つのカスタムフィールドに対して同じコードを繰り返していたことが問題の原因でした。そのファイルから 2 つ目のカスタムフィールドを削除すると、再び正常に動作するようになりました。

このスケルトンコードで、複数のフィールドを追加したい場合、各フィールドはスタンドアロンのプラグインである必要がありますか?

このチュートリアルを見つけられてとても嬉しいです。カスタムユーザーフィールドでこれらのテンプレートを使用するために、どの程度、あるいはどのような調整が必要になるか疑問に思っています。

いいえ、追加フィールドのために追加のコードを追加するだけです。ほとんどの場合、既存のコードを複製するだけです。たとえば、

add_preloaded_topic_list_custom_field(FIELD_NAME_1)
add_preloaded_topic_list_custom_field(FIELD_NAME_2)

カスタムユーザーフィールドの最初の参照先は、「/admin/customize/user_fields」で、UIで追加できます。より詳細な制御が必要な場合、プロセスはトピックやカテゴリと非常によく似ていますが、ユーザーフィールドでは実際にはフロントエンド要素は必要ありません。

実際、私たち(Pavilion)はカスタムフィールドプラグイン(WordPressのACFに類似)を作成することを考えており、最初はCustom Wizard plugin.のカスタムフィールド管理インターフェイスのようになります。

実際、すでにCustom Wizardプラグインをカスタムフィールドマネージャーとして使用している人もいます。インスタンス上のすべてのカスタムフィールド(ソースを問わず)をリストし、サポートする任意のモデルに任意のタイプのフィールドを追加できます。

フロントエンドサポートは追加されません。たとえば、Topic Custom Field教育プラグインに示されているようなもの(カスタムウィザードプラグインのコンテキストでは機能しません)は、そのため、それを別のプラグインに分割することを考えています。

「いいね!」 3

@angus、それは素晴らしいと思います。

特にフロントエンドサポートが追加されるなら。

管理者がさまざまなクラスにカスタムフィールドを簡単に追加し、ユーザーがそれらを入力できるようにし(例:トピック、投稿、ユーザープロフィール)、フロントエンドでそれらを表示する方法があれば素晴らしいと思います。

現在のカスタムユーザーフィールドで手に入れられない主な点は、フィールドタイプの多様性です。現在は4種類に限定されていると思いますが、Custom Wizardsプラグインで利用可能なオプションがあれば嬉しいです。

理想的には、さまざまな種類のカスタムフィールドを多数備えた、かなり高度な検索可能/フィルタリング可能/ソート可能なユーザーディレクトリを構築したいと考えています。とりあえずCustom Wizardsで機能するかどうか実験し、皆さんがCustom Fieldsプラグインに投資してくれることを願っています。

ありがとうございます!

@angus

まず、このプラグインを本当にありがとうございます。

複数のカスタムフィールドに対応するため、このプラグイン(トピックへのカスタムフィールド)の動作例をお持ちでしょうか? 1つのカスタムフィールドを追加することに成功し、いくつかの変更も問題なく行いました。

コードを複製したり、変更したり、別のプラグインを追加したりしてみました。

コードリポジトリや共有していただける例をお持ちの方はいらっしゃいますか? どんな助けでも大変感謝いたします。

「いいね!」 1

こんにちは @Joe_Stanton さん、

私も何度か同じことをしましたが、カスタムフィールドを、名前とタイプを持つオブジェクトの配列に格納する方法を取りました。

例えば:

fields = [
  { name: 'isClassifiedListing', type: 'boolean' },
  { name: 'listingStatus', type: 'string' },
  { name: "listingDetails", type: 'json' }
]

その後、フィールドをループして、このトピックで言及されているロジックを適用します。私が取り組んでいるプラグインでどのように使用されているかの例はこちらで見ることができます。ただし、関連するコードは以下のとおりです。

サーバー側:

  # カスタムフィールドの登録
  fields.each do |field|
    # フィールドを登録
    register_topic_custom_field_type(field[:name], field[:type].to_sym)

    # ゲッターメソッド
    add_to_class(:topic, field[:name].to_sym) do
      if !custom_fields[field[:name]].nil?
        custom_fields[field[:name]]
      else
        nil
      end
    end

    # セッターメソッド
    add_to_class(:topic, "#{field[:name]}=") do |value|
      custom_fields[field[:name]] = value
    end

    # トピック作成時の更新
    on(:topic_created) do |topic, opts, user|
      topic.send("#{field[:name]}=".to_sym, opts[field[:name].to_sym])
      topic.save!
    end

    # トピック編集時の更新
    PostRevisor.track_topic_field(field[:name].to_sym) do |tc, value|
      tc.record_change(field[:name], tc.topic.send(field[:name]), value)
      tc.topic.send("#{field[:name]}=".to_sym, value.present? ? value : nil)
    end

    # トピックへのシリアライズ
    add_to_serializer(:topic_view, field[:name].to_sym) do
      object.topic.send(field[:name])
    end

    # フィールドの事前読み込み
    add_preloaded_topic_list_custom_field(field[:name])

    # トピックリストへのシリアライズ
    add_to_serializer(:topic_list_item, field[:name].to_sym) do
      object.send(field[:name])
    end
  end

同様にクライアント側でフィールドをシリアライズします:


 const CUSTOM_FIELDS = [
  { name: "isClassifiedListing", type: "boolean" },
  { name: "listingStatus", type: "string" },
  { name: "listingDetails", type: "json" },
];

  // カスタムフィールドのシリアライズ:
  CUSTOM_FIELDS.forEach((field) => {
    api.serializeOnCreate(field.name);
    api.serializeToDraft(field.name);
    api.serializeToTopic(field.name, `topic.${field.name}`);
  });
「いいね!」 2

ありがとうございます、@keegan さん!

プラグインの plugin.rb ファイルは正しく設定できたと思います。ループも正しく見えます。

しかし、topic-custom-field-initializer.js ファイルで苦労しています。両方のファイルのコードを以下に示します。initializer.js ファイルに関するヒントはありますか?本当にあと一歩だと思うのですが、新しいトピックを作成するときに、listingDetails フィールドの 1/3 のフィールドが表示されますが、isClassifiedListinglistingStatus がまだ表示されません。

enabled_site_setting :topic_custom_field_enabled
register_asset 'stylesheets/common.scss'

after_initialize do
  fields = [
  { name: 'isClassifiedListing', type: 'boolean' },
  { name: 'listingStatus', type: 'string' },
  { name: "listingDetails", type: 'json' }
]

 fields.each do |field|

  register_topic_custom_field_type(field[:name], field[:type].to_sym)

   add_to_class(:topic, field[:name].to_sym) do
      if !custom_fields[field[:name]].nil?
        custom_fields[field[:name]]
      else
        nil
      end
    end

   add_to_class(:topic, "#{field[:name]}=") do |value|
      custom_fields[field[:name]] = value
   end

   on(:topic_created) do |topic, opts, user|
      topic.send("#{field[:name]}=".to_sym, opts[field[:name].to_sym])
      topic.save!
   end

    PostRevisor.track_topic_field(field[:name].to_sym) do |tc, value|
      tc.record_change(field[:name], tc.topic.send(field[:name]), value)
      tc.topic.send("#{field[:name]}=".to_sym, value.present? ? value : nil)
    end

    add_to_serializer(:topic_view, field[:name].to_sym) do
      object.topic.send(field[:name])
    end

  add_preloaded_topic_list_custom_field(field[:name])

    # Serialize to the topic list
    add_to_serializer(:topic_list_item, field[:name].to_sym) do
      object.send(field[:name])
    end

end

end

Initializer.js

import { withPluginApi } from 'discourse/lib/plugin-api';
import discourseComputed from "discourse-common/utils/decorators";
import { alias } from '@ember/object/computed';
import { isDefined, fieldInputTypes } from '../lib/topic-custom-field';

export default {
  name: "topic-custom-field-intializer",
  initialize(container) {


    const CUSTOM_FIELDS = [
      { name: "isClassifiedListing", type: "boolean" },
      { name: "listingStatus", type: "string" },
      { name: "listingDetails", type: "json" },
    ];

    CUSTOM_FIELDS.forEach((field) => {

    withPluginApi('0.11.2', api => {

      api.registerConnectorClass('composer-fields', 'composer-topic-custom-field-container', {
        setupComponent(attrs, component) {
          const model = attrs.model;

          if (!isDefined(model[field.name]) && model.topic && model.topic[field.name]) {
            model.set(field.name, model.topic[field.name]);
          }

          let props = {
            fieldName: field.name,
            fieldValue: model.get(field.name)
          }
          component.setProperties(Object.assign(props, fieldInputTypes(field.type)));
        },

        actions: {
          onChangeField(fieldValue) {
            this.set(`model.${field.name}`, fieldValue);
          }
        }
      });

      api.registerConnectorClass('edit-topic', 'edit-topic-custom-field-container', {
        setupComponent(attrs, component) {
          const model = attrs.model;

          let props = {
            fieldName: field.name,
            fieldValue: model.get(field.name)
          }
          component.setProperties(Object.assign(props, fieldInputTypes(field.type)));
        },

        actions: {
          onChangeField(fieldValue) {
            this.set(`buffered.${field.name}`, fieldValue);
          }
        }
      });

      api.serializeOnCreate(field.name);
      api.serializeToDraft(field.name);
      api.serializeToTopic(field.name, `topic.${field.name}`);

      api.registerConnectorClass('topic-title', 'topic-title-custom-field-container', {
        setupComponent(attrs, component) {
          const model = attrs.model;
          const controller = container.lookup('controller:topic');

          component.setProperties({
            fieldName: field.name,
            fieldValue: model.get(field.name),
            showField: !controller.get('editingTopic') && isDefined(model.get(field.name))
          });

          controller.addObserver('editingTopic', () => {
            if (this._state === 'destroying') return;
            component.set('showField', !controller.get('editingTopic') && isDefined(model.get(field.name)));
          });

          model.addObserver(field.name, () => {
            if (this._state === 'destroying') return;
            component.set('fieldValue', model.get(field.name));
          });
        }
      });

      api.modifyClass('component:topic-list-item', {
        customFieldName: field.name,
        customFieldValue: alias(`topic.${field.name}`),

        @discourseComputed('customFieldValue')
        showCustomField: (value) => (isDefined(value))
      });

    });


    });
  }
}

「いいね!」 1

テストはしていませんが、1/3のフィールドしか表示されない理由は、ループ処理で一意でないコネクタクラスを登録し、前のものを上書きしているためだと考えています。

一般的に、クライアントサイドでは、カスタムフィールドをループ処理してAPIメソッドを宣言するのではなく、各フィールドのコンポーネントを個別に定義するか、少なくとも別々の処理として定義することをお勧めします。各フィールドに関連するロジックが異なる可能性があるためです。

ループ処理して宣言するのは、この部分だけでしょう。

  api.serializeOnCreate(field.name);
      api.serializeToDraft(field.name);
      api.serializeToTopic(field.name, `topic.${field.name}`);

残りのコンポーネントについては、各ケースで個別のロジックを作成するのが最善でしょう。

「いいね!」 3

@keegan うまくいきました!多くの洞察をありがとうございました。あなたの助けなしではできませんでした。

「いいね!」 4