如何向模型添加自定义字段

这是一个教育插件集合,演示了如何在 Discourse 的不同模型中添加自定义字段。它们旨在作为学习工具,供那些希望学习如何构建 Discourse 插件的人使用。

GitHub-Mark 如何为主题添加自定义字段
GitHub-Mark 如何为分类添加自定义字段

适用人群

这些插件适用于希望进一步了解如何创建 Discourse 插件的人员。在开始使用这些插件之前,您应完成 Discourse 插件创建入门指南

您可以使用这些插件仅为您的 Discourse 实例添加自定义字段,但即便如此,您仍需对代码进行一些修改。它们并非设计为在实时服务器上直接“即插即用”。

工作原理

除了包含可运行的代码外,每个插件还以注释的形式提供逐步说明,解释代码的功能。例如:

## 
# type:        step
# number:      1
# title:       注册字段
# description: 在此处告知 Discourse 我们正在添加何种类型的字段。
#              您可以注册字符串、整数、布尔值或 JSON 字段。
# 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 个赞

感谢您为我们构建这个插件,
我在安装插件后遇到了以下错误:

哎呀
为本网站讨论论坛提供支持的软件遇到了意外问题。对此造成的不便,我们深表歉意。
有关该错误的详细信息已被记录,并已生成自动通知。我们将对此进行检查。
无需采取进一步操作。但是,如果错误持续存在,您可以通过在网站的反馈类别中发布讨论主题,提供包括复现步骤在内的更多详细信息。

嘿,你是在本地开发环境中运行吗?如果是的话,能否私信发送你的开发日志?如果你的开发环境配置正确,这个插件就能正常工作。

1 个赞

我阅读了文档,但没能完全理解。您能否抽出时间制作一个视频教程,从搭建本地开发环境开始,逐步演示如何安装此插件并添加自定义字段?
目前要使用此插件实在太困难了。
如果可能的话,能否为“数字”类型的字段升级搜索功能?

我愿意为这个插件捐款,恳请您提供支持!

@angus 整理的这份资源比我最初意识到的还要有用。

不仅提供了添加自定义字段的代码,而且解释清晰,其中大部分代码可以直接嵌入到任何插件中,因为这些代码主要使用了像 FIELD_NAMEFIELD_VALUE 这样的变量,你可以在 plugin/config/settings.yml 中定义它们(你还需要确保你的插件文件结构与 @angus 提供的 GitHub 代码中的结构一致)。阅读这段代码也让我对之前见过但从未真正理解的某些 Discourse 函数和方法有了更深入的了解。

到目前为止,这段代码在创建和保存话题自定义字段方面表现良好。不过我有两个问题:

  1. 话题列表错误:当我尝试加载一个包含在添加自定义字段之前创建的话题的分类列表(即该分类下的话题列表)时,似乎会抛出错误。它显示了异常页面,并列出了以下错误:
    Attempting to access a non preloaded custom field, this is disallowed to prevent N+1 queries. 建议如何解决这个问题?

  2. 我能否只为特定分类中的话题应用自定义字段?例如,假设我有分类 1、分类 2 和分类 3,我只希望自定义字段输入框在话题属于分类 3 时才显示,并且只在该情况下保存该字段。有办法实现这一点吗?

1 个赞

我们(Pavilion)未来会制作类似的内容,但目前只有代码和步骤。如果你卡在某个具体问题,请在 Dev 频道发帖,并详细描述该问题。

我已在“主题自定义字段”插件的主题中补充了一个子步骤,演示了如果在主题列表中使用字段时如何预加载它们。

你需要一个“分类自定义字段”来指定应在哪些分类中显示。我在本插件中对分类自定义字段也采用了相同的处理方式,并提供了同样详细的分步说明:

将这两个教育类插件结合起来可能还无法完全实现你的目标,但你可以在此基础上继续完善。

2 个赞

这太棒了,@angus。非常感谢。

视频总是很好的——我非常赞同让事情尽可能简单,但我不认为从 @angus 整理的这些资源中获取关键值需要视频。这些资源提供了你实现特定目标所需的代码(例如创建有效的主题自定义字段或分类自定义字段)。视频可能只是 @angus 或其他人讲解如何实现该资源,但这很直接,我们完全可以在这里把步骤列出来。

需要明确的是,这些资源并不是那种你可以直接“即插即用”并自定义论坛的插件。相反,它们高效地帮助你理解如何编写自己的插件中的自定义字段。


我是这样使用这些资源的:

你需要在 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 模板中的插件出口(plugin outlet)中添加自定义字段的表单。该表单的设置位于 此处(assets/javascripts/discourse/lib/[custom-field-name].js.es6),因此你可能也想自定义它以确保表单正常工作。

@angus,如果我说错了什么,请随时纠正。

在按照上述步骤设置自定义字段并熟悉之后,我开始进一步自定义(例如,在表单功能上更有创意),但这确实是一个极其有帮助的起点,为我节省了大量时间。

完成之后,我确实有一些问题(就像我之前问的那样),但从那里继续来看,在 Dev 频道获得回复似乎是最有帮助的方式。

3 个赞

描述得很棒!没错,这正是它们的预期用途 :+1:

1 个赞

编辑:我最初在此发布了关于如何根据自定义字段检索项目的问题,但后来认为该问题足够不同,值得单独发帖。因此,我在此单独发布:点击这里

2 个赞

我在遵循主题自定义字段示例时,Composer 出现了异常行为。

当我点击“创建主题”按钮(例如在分类展示页面上——实际上在站点的任何位置)时,Composer 无法打开,并出现以下错误:

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

当我移除这段代码时,“创建主题”按钮又能正常工作(正确打开 Composer)。但一旦将代码加回去,Composer 错误又会重现。

我之前在插件中使用过这段代码,并未出现问题。但现在它却引发了 Composer 错误(即使我已经移除了插件中所有与 Composer 或“创建主题”按钮相关的代码)。

当然,这段代码很重要——它负责序列化自定义字段。但看起来它与 Composer 存在冲突。请问有什么解决办法吗?

我找到了原因——我试图在 主题自定义字段初始化器 中添加两个独立的自定义字段。不知为何,这导致了一些冲突。可能有一种正确的方式可以在该文件中添加两个自定义字段,但我的代码只是重复了两次相同的代码来分别处理两个自定义字段,从而引发了问题。当我从该文件中移除第二个自定义字段后,一切又恢复正常了。

使用这段骨架代码,如果我想添加多个字段,每个字段都必须是一个独立的插件吗?

我很高兴找到这个教程,我想知道,为了让这些模板适用于自定义用户字段,我需要对它们进行多少调整(如果有的话)?

不,你只需要为额外的字段添加额外的代码。在大多数情况下,只需复制现有代码即可,例如:

add_preloaded_topic_list_custom_field(FIELD_NAME_1)
add_preloaded_topic_list_custom_field(FIELD_NAME_2)

自定义用户字段的第一个查找位置是 /admin/customize/user_fields,它提供了一个用户界面来添加它们。如果你想获得更精细化的控制,过程与主题和类别非常相似,但你实际上不需要前端元素来处理用户字段。

实际上,我们(Pavilion)正在考虑创建一个自定义字段插件(类似于 WordPress 的 ACF),它最初看起来会有点像 Custom Wizard 插件 中的自定义字段管理界面。

实际上,有些人已经将 Custom Wizard 插件用作自定义字段管理器。它可以列出你实例中的所有自定义字段(来自任何来源),并允许你将任何类型的字段添加到支持它们的模型中。

它不提供前端支持,例如在 Topic Custom Field 教育插件中所示的那样(这在 Custom Wizard 插件的上下文中不起作用),这就是为什么我们考虑将其拆分为一个单独的插件。

3 个赞

@angus,我认为我会喜欢这个。

尤其是如果它增加了前端支持的话。

我希望有一种简单的方法让管理员为不同的类添加自定义字段,允许用户填写它们(例如,添加到主题、帖子、用户配置文件),并有一种前端来显示它们。

我目前在自定义用户字段方面最大的问题是字段类型的多样性。现在我猜它只限制在 4 种,而我希望拥有 Custom Wizards 插件提供的选项。

理想情况下,我想构建一个相当高级的、可搜索/可过滤/可排序的用户目录,其中包含许多不同类型的自定义字段。我现在将尝试 Custom Wizards,看看它是否有效,并希望你们会投资 Custom Fields 插件。

谢谢!

@angus

首先,非常感谢您提供的这个插件。

您是否有一个可用的插件示例(自定义字段到主题)来处理多个自定义字段?我已成功添加了一个自定义字段,并对一些修改进行了修改,没有出现问题。

我尝试过复制代码、修改并添加另一个插件等。

是否有人愿意与我分享代码库或示例?任何帮助都将不胜感激。

1 个赞

@Joe_Stanton

我已经做过几次了,我做的方法是将自定义字段存储在一个数组中,其中包含一个具有自定义字段名称和类型的对象。

例如:

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

然后我遍历字段以应用此主题中提到的逻辑。您可以在我正在开发的插件中看到一个示例 here。但是,相关的代码如下。

示例

在服务器端:

  # 自定义字段注册
  fields.each do |field|
    # 注册字段
    register_topic_custom_field_type(field[:name], field[:type].to_sym)

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

    # Setter 方法
    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 文件有什么建议吗?我觉得我快成功了,在创建新主题时,我得到了 1/3 的字段,即 listingDetails 字段,但仍然缺少 isClassifiedListing 和 listingStatus

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 个赞