Add a custom per-user setting in a plugin

I just went through this process and experienced a bunch of trial and error, so I thought I’d document my findings to help the next developer to come along.

The things I needed:

  • Register your custom field type (mine was boolean, default is string)

    # plugin.rb
    User.register_custom_field_type 'my_preference', :boolean
    
  • Register that the custom field should be editable by users. Syntax matches that of params.permit(...)

    # plugin.rb
    register_editable_user_custom_field :my_preference # Scalar type (string, integer, etc.)
    register_editable_user_custom_field [:my_preference , my_preference : []] # For array type
    register_editable_user_custom_field [:my_preference,  my_preference : {}] # for json type
    
  • Add them to the fields serialized with the CurrentUserSerializer

    # plugin.rb
    DiscoursePluginRegistry.serialized_current_user_fields << 'my_preference'
    
  • Create a component to display your user preference

    // assets/javascripts/discourse/templates/components/my-preference.hbs
    <label class="control-label">My custom preferences!</label>
    {{preference-checkbox labelKey="my_plugin.preferences.key" checked=user.custom_fields.my_preference}}
    
  • Connect that component to one of the preferences plugin outlets (mine was under ‘interface’ in the user preferences)

    # assets/javascripts/discourse/connectors/user-preferences-interface/my-preference.hbs
    {{my-preference user=model}}
    
  • Ensure ‘custom fields’ are saved on that preferences tab

    import { withPluginApi } from 'discourse/lib/plugin-api'
    
    export default {
      name: 'post-read-email',
      initialize () {
         withPluginApi('0.8.22', api => {
    
           api.modifyClass('controller:preferences/emails', {
             actions: {
               save () {
                 this.get('saveAttrNames').push('custom_fields')
                 this._super()
               }
             }
           })
    
         })
      }
    }
    

This document is version controlled - suggest changes on github.

33개의 좋아요

Nice! I attempted the same in GitHub - mozilla/discourse-post-read-email: INACTIVE - http://mzl.la/ghe-archive - A discourse plugin to give users the option of marking posts as read when emailed · GitHub and arrived at almost the same result.

My only differences were I didn’t hunt down User.register_custom_field_type and so used my own ugly workaround. (I’ll switch to register_custom_field_type when I get the chance.)

And I think I came up with a slightly neater solution for saving the field, I patch the preferences controller to save custom fields alongside everything else, so the field is saved when the “Save” button is clicked, rather than when it’s toggled:

import { withPluginApi } from 'discourse/lib/plugin-api'

export default {
  name: 'post-read-email',
  initialize () {
     withPluginApi('0.8.22', api => {

       api.modifyClass('controller:preferences/emails', {
         actions: {
           save () {
             this.saveAttrNames.push('custom_fields')
             this._super()
           }
         }
       })

     })
  }
}

This should work for all preferences controllers, as they all seem to use saveAttrNames.

10개의 좋아요

As a follow-up here, it turns out that inline-edit-checkbox is available only in the adminjs package, meaning this is currently Bad Advice™. I’ve resorted to using the method suggested above alongside the preference-checkbox component

{{preference-checkbox labelKey="my_plugin.preferences.key" checked=model.custom_fields.my_field}}

which works for all users.

Also, @LeoMcA, I had to modify your preferences hack slightly to work with the interface page since saveAttrNames was a computed property there.

this.get('saveAttrNames').push('custom_fields')
5개의 좋아요

Per DEV: Allow plugins to whitelist specific user custom_fields for editi… · discourse/discourse@4382fb5 · GitHub, custom fields must now be added to a whitelist to allow editing by users. All that is needed is a single line in plugin.rb:

register_editable_user_custom_field :my_field

@gdpelican @LeoMcA I have updated the OP with this extra step, and also pulled in the comments from your second and third posts. Please feel free to update anything else you feel is necessary.

8개의 좋아요

Perfect, thanks for this - I had fixing discourse-post-read-email on my todolist after last week’s security commit, and this makes it a whole lot easier!

Question (which may belong in a seperate post):

Will this serialize the custom field as an array, even if it only has a single element in it? I’ve been having to use the following pattern in a seperate plugin, so that:

user.custom_fields["field1"] = [ :item ]
user.save_custom_fields

becomes:

Array(user.custom_fields["field1"])
> [ :item ]

rather than:

user.custom_fields["field1"]
> :item
2개의 좋아요

This change only deals with saving custom fields, so I don’t think it will affect how they are serialized.

That said, I do know that there are a lot of weird edge cases relating to custom fields which we are hoping to address in a few weeks time (after 2.1 is released). What you describe above looks like one of those weird cases that we need to improve.

6개의 좋아요

Note that if the user custom field is a JSON string, you need to include the keys, or an empty hash (if the keys are dynamic), for it to be permitted, e.g.

register_editable_user_custom_field geo_location: {}
3개의 좋아요

hm, in a slight pickle actually. If the custom field is JSON, in order to save it, you need to pass a hash, i.e.

register_editable_user_custom_field geo_location: {}

will permit

{"custom_fields"=>{"geo_location"=>{"lat"=>"-37.7989239", "lon"=>"144.8929753", "address"=>"Barkly Street, Footscray, City of Maribyrnong, Greater Melbourne, Victoria, 3011, Australia", "countrycode"=>"au", "city"=>"", "state"=>"Victoria", "country"=>"Australia", "postalcode"=>"3011", "boundingbox"=>["-37.7989854", "-37.7988961", "144.8928258", "144.8931743"], "type"=>"tertiary"}}>

However, if the param is empty (e.g. the user clears the field), the custom_field is interpreted as a string

{"custom_fields"=>{"geo_location"=>"{}"}}

and is not permitted.

There isn’t an easy way around this in the current structure, i.e. the way user_params are added to in the users_controller

permitted << { custom_fields: User.editable_user_custom_fields }

Unless I’m missing something, perhaps some additional provision needs to be made for user_custom_fields that are typecast as JSON?

1개의 좋아요

I had a similar problem with arrays, not sure if the same will work for JSON. To allow an empty array, you have to permit ‘scalar’ values as well as an array:

register_editable_user_custom_field :geo_location
register_editable_user_custom_field geo_location: []

Or if you’re feeling fancy it can be combined into one line:

register_editable_user_custom_field [ :geo_location, geo_location: [] ]

This is the same behaviour as params.permit(...), so I hesitate to call it a bug. Maybe we can call it a ‘quirk’ :wink:

Let me know if that approach works for JSON - if not we can work out another solution

6개의 좋아요

:facepalm: of course. Just add another. It’s been a long day. Thanks!

3개의 좋아요

Visiting this again, it feels like there are too many steps here for something that the core plugin system has a command for. On the backend, I have to write the following to make this go:

# plugin.rb
register_editable_user_custom_field :my_setting
User.register_custom_field_type 'my_setting', :boolean
DiscoursePluginRegistry.serialized_current_user_fields << 'my_setting'

but I feel like I should be able to do this:

# plugin.rb
register_editable_user_custom_field :my_setting, :boolean

You could even avoid people running into that nasty array snag by making the plugin system support the following cases:

register_editable_user_custom_field :my_setting, :array
register_editable_user_custom_field :my_setting, :object

@david

10개의 좋아요

사용자 정의 필드를 설정하는 간단한 예를 보여주는 매우 유용한 게시물입니다. 비슷한 방식으로 간단한 주제(토픽) 사용자 정의 필드를 설정하는 데 도움을 줄 수 있는 자료가 있을까요? 아래에서 시도해 보고 있습니다:

아직 제대로 작동하지 않습니다. 도움을 주시면 정말 감사하겠습니다.

1개의 좋아요

David,

이것이 더 이상 작동하지 않는 것 같습니다?

우리는 다음과 같은 코드를 사용해 왔습니다:

  register_editable_user_custom_field [:geo_location,  geo_location: {}] if defined? register_editable_user_custom_field
  register_editable_user_custom_field geo_location: {} if defined? register_editable_user_custom_field

이 코드를 사용하여 사용자 커스텀 필드에 JSON 객체를 저장할 수 있었지만, 이제 사이트 재빌드를 방해하고 있습니다!

우리는 이 코드를 오랫동안 사용해 왔습니다.

빌드 중에 발생하는 에러는 다음과 같습니다:

ArgumentError: wrong number of arguments (given 0, expected 1)
/var/www/discourse/lib/plugin/instance.rb:170:in `register_editable_user_custom_field'
/var/www/discourse/plugins/discourse-locations/plugin.rb:95:in `block in activate!'

혼란을 가중시키는 것은, 이 코드가 개발 환경에서는 작동하는 것처럼 보이지만 프로덕션 빌드 중에만 실패한다는 점입니다.

이 코드를 제거하면 사이트는 빌드되지만, 사용자 커스텀 필드가 저장되지 않고 조용히 실패합니다.

수년간 이 부분이 변경된 적이 없는 것으로 보입니다:

새로운 버전의 Rails가 이제 이 동작을 차단하고 있는 걸까요?

2개의 좋아요

@Falco 이건 Ruby 3.x와 관련이 있을까요?

2개의 좋아요

참고로 개발용으로 로컬에 2.7.1이 설치되어 있습니다 (아… 실수했네요) … 지금 바로 수정하겠습니다.

3개의 좋아요

네, 확실히 Ruby 3.1.x와 관련된 문제입니다. 2.7.x에서는 잘 동작합니다.

2개의 좋아요

로컬 환경에서 현재 Ruby를 사용하여 플러그인을 기본 설정으로 설치하고 활성화해 두었는데, 이 오류를 어떻게 발생시킬 수 있나요?

1개의 좋아요

글쎄요, 그게 바로 문제입니다. rbenv가 3.0.2 이후의 Ruby 설치를 허용하지 않고, 개발 환경에서는(아, 뭘 놓치고 있는 걸까요?) 에러를 재현할 수 없습니다. 하지만 production_fixes 브랜치에서 Locations 플러그인이 적용된 최신 tests-passed 인스턴스를 빌드하려고 하면 즉시 문제가 발생합니다(브랜치 이름은 무시해 주세요. 그건 깨져 있거든요).

1개의 좋아요

참고로 현재 버전은 3.1.3입니다. 제안에 개방적이시다면, 저는 asdf가 아주 잘 작동한다고 말씀드리고 싶습니다.

좋아요, 시도해 보겠습니다.

2개의 좋아요

죄송합니다. 해당 브랜치의 빌드 오류를 실제로 해결한 것 같습니다. 동작할 거라고 생각하지 않았는데, 이 방법으로 적어도 빌드는 되는 것 같습니다. 현재 기능 테스트를 진행 중입니다:

위에서 David가 제안한 이 트릭을 사용하면 되는 것 같습니다:

 register_editable_user_custom_field [:geo_location,  geo_location: {}] if defined? register_editable_user_custom_field
1개의 좋아요