How to add custom fields to models

Edit: I originally posted my question regarding how to retrieve items based on a custom field here, but decided the question was sufficiently different to warrant its own post. So I posted separately here.

2 Likes

I’m experiencing strange behavior with the composer following the topic custom fields example.

When I hit the “create topic” button (for example, on the category show page–but anywhere on the site), the composer fails to open, and I get this error:

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

I first started seeing this error when I tried to add a new ‘create topic’ button on a new page, but since then, even I remove that new button and even if I remove any related code, the error persists.

In some way, I think the following code–from the topic-custom-field-initializer–is causing the problem:

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

When I remove this code, the create topic buttons work again (properly opening the composer). When I put the code back in, the composer error comes back.

I have had this code in my plugin previously without problem. But now it causes the composer error (even if I have removed any composer or create-topic button -related code in my plugin).

Of course, this code is important–it serializes the custom field. But it seems that it is conflicting with composer. Any ideas on how to fix?

I was able to figure out the cause–I was trying to add two separate custom fields to the topic custom fields initializer. For some reason that was causing some interference. There probably is a way to properly add two custom fields to that file, but my code, just repeating the same code for two separate custom fields, was causing problems. It worked again fine when I removed the second custom field from that file.

With this skeleton code, if I wanted to add multiple fields, would each field have to be a standalone plugin?

I’m really excited to have found this tutorial and I’m wondering, how much, if any, tweaking would I need to do to these templates to have them work for custom user fields?

No, you just need to add in additional code for the additional field. In most cases by just duplicating the existing code, e.g.

add_preloaded_topic_list_custom_field(FIELD_NAME_1)
add_preloaded_topic_list_custom_field(FIELD_NAME_2)

The first place to look for custom user fields is /admin/customize/user_fields which gives you a UI to add them. If you want to have more granular control, the process looks very similar to topic and category, but you don’t actually need the frontend elements with user fields.

Actually we’re (Pavilion) thinking of making a custom fields plugin (analgous to ACF for wordpress) which would initially look a bit like the custom fields admin interface in the Custom Wizard plugin.

Actually, some people already use the Custom Wizard plugin as a custom field manager. It lists all custom fields on your instance (from any source) and lets you add a field of any type to any model that supports them.

It doesn’t add in frontend support, e.g. like that show in the Topic Custom Field educational plugin (and that wouldn’t work in the context of the custom wizard plugin), which is why we’re thinking of breaking that out into a seperate plugin.

2 Likes

@angus, I think I would love this.

Especially if it adds the front-end support.

I would love to have an easy way for admins to add custom fields to different classes, allow users to fill them in (eg, to topics, posts, user profiles), and have a way for the front-end to show them.

The main thing I can’t get with the current custom user fields is a variety of field types. Right now it’s limited to 4 I think, and I’d love to have the options that are available with the Custom Wizards plugin.

Ideally, I want to build a pretty advanced searchable/filterable/sortable user directory with lots of custom fields of many types, I’ll experiment with Custom Wizards to see if it’ll work for now and hope that y’all do invest in the Custom Fields plugin.

Thanks!

@angus

First off, thank you so much for this plugin.

Do you possibly have a working example of this plugin (custom field to topic) to account for multiple custom fields? I was able to successfully add one custom field and made a couple modifications without a problem.

I’ve tried duplicating the code, modifying and adding an additional plugin, etc…

Does anyone have a code repo or example they are willing to share with me? Any help would be greatly appreciated.

1 Like

Hey @Joe_Stanton,

I’ve done it a couple times, and the way I’ve done it was to store the custom fields in an array with an object with a name and type for the custom field.

For example:

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

Then I loop through the fields to apply the logic that’s mentioned in the this topic. You can see an example of it used in a plugin I’m working on here. However, the relevant code is below.

Example

In the server side:

  # Custom Field Registration
  fields.each do |field|
    # Register the fields
    register_topic_custom_field_type(field[:name], field[:type].to_sym)

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

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

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

    # Update on Topic Edit
    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

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

    # Preload the Fields
    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

Similarly on the client side to serialize the fields:


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

  // Serialize Custom Fields:
  CUSTOM_FIELDS.forEach((field) => {
    api.serializeOnCreate(field.name);
    api.serializeToDraft(field.name);
    api.serializeToTopic(field.name, `topic.${field.name}`);
  });
1 Like

Thanks @keegan !

I think I got the plugin.rb file setup correctly, the loop looks correct.

However, I’m struggling on the topic-custom-field-initializer.js file. Here is my code for both files. Any pointers for the initializer.js file? I think I’m really close here, when creating a new topic, I am getting 1/3 fields, the listingDetails field but still missing the isClassifiedListing and 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 Like

I haven’t tested it, but, I believe the reason you are seeing only 1/3 fields is its looping over and registering a non-unique connector class and overriding the previous one.

In general, for the client side, rather than looping through the custom fields and declaring the api methods. I suggest you define components for each field separately, or at least separate actions as you’ll probably need to have different logic associated with each field?

The only portion I would loop over and declare is this:

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

The rest of the components, its probably best to create separate logic for each case.

1 Like

@keegan that worked! thank you so much for all the insights. Couldn’t have done it without your help.

3 Likes

Is it possible to add a custom field to tags using the same logic?

1 Like

Has anyone applied this to groups? If so, can you share what you did?

I guess this would do the trick. Hopefully it’s still up-to-date.

No, tags don’t have custom fields. What are you trying to accomplish?

Yes, that repository should work. Just change all instances of my_field to the name of your field.

1 Like

I am trying to create the following plugin: Creating a User - Tag relation plugin

Is there some more documentation about how to customize these group fields? It works indeed great with this boilerplate code. But how to extend it properly?

I for example want to add a few input fields, like:

<div class="control-group">
  <label class="control-label" for="map">Chapter coordinates</label>
  <input name="chapter_coordinates" id="chapter_coordinates" class="ember-text-field ember-view input-xlarge" value={{group.custom_fields.group_coordinates}} placeholder="E.g. 52.3727598,4.8936041" type="text">
  <div class="control-instructions">Fetch coordinates from https://nominatim.openstreetmap.org/</div>
</div>

But I’m just guessing what to do here and then add the rest of the Discourse stuff. The example above is to use a field for coordinates. I intent to use the g.json to create a map based on the group metadata.

I also intend to add a checkbox for emailing the group with a mail setup for that group.

<div class="control-group">
  <label class="control-label" for="map">Contact chapter by email</label>
  {{input type="checkbox" checked=group.custom_fields.contact_group_by_email}}
  <span>{{i18n 'admin.groups.contact_group_by_email.label'}}</span>
</div>

However, this field is best placed in the interaction tab. How to know what to do to get it there? I would like to become more proficient with this. But I get the idea that this information is just in the code, not documented somewhere. Which is probably fine, but just takes more time and effort to find what I’m looking for. Although what I want to add is quite small, just a few fields to the groups :nerd_face:

Yes, it can be tricky and a bit overwhelming when starting to navigate the Discourse codebase. It sounds like you’re trying to work with GeoJSON. Have you considered using or extending the Locations Plugin? That’s already set up to work with GeoJSON in Discourse.

A course in plugin development?

I’ve been considering running a free course in discourse plugin development, which is essentially what you need. I’ve already written the course materials for a course in theme development (see below). If 30 people vote for one in plugin development I’ll write a give a course (via zoom) in it.

  • Write and give course in plugin development
  • Don’t write and give a course in plugin development

0 voters

Introduction to theme development
  1. GitHub - pavilionedu/discourse-theme-introduction
  2. GitHub - pavilionedu/discourse-theme-css
  3. GitHub - pavilionedu/discourse-theme-colors
  4. GitHub - pavilionedu/discourse-theme-html-one
  5. GitHub - pavilionedu/discourse-theme-html-two
  6. GitHub - pavilionedu/discourse-theme-javascript-one
  7. GitHub - pavilionedu/discourse-theme-javascript-two

*ps if it hits 30 please let me know.

2 Likes

Awesome! I actually will use a fork of this Fairphone community map. They use the raw YAML output of a topic. I’ve modified my fork to use the data from g.json to draw markers on the map. I just miss a few fields such as coordinates and a boolean to use an email address in the map. Maybe some more, but first I’ll need to know how things work. Thanks for the links! Will have a look this week and see how far I’ll get. A course would be awesome too! My JS/Ruby skills are now though. Mainly Python/Bash and some crumbs from other language’s.

Is it possible to add custom fields to posts? Ideally from the post composer.