모델에 사용자 지정 필드 추가하는 방법

이것은 Discourse의 다양한 모델에 사용자 정의 필드를 추가하는 방법을 보여주는 교육용 플러그인 모음입니다. Discourse 플러그인 개발 방법을 배우려는 사람들을 위한 학습 도구로 의도되었습니다.

GitHub-Mark 주제(Topic)에 사용자 정의 필드 추가하기
GitHub-Mark 카테고리(Category)에 사용자 정의 필드 추가하기

대상 독자

이 플러그인들은 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개의 좋아요

저희 플러그인을 만들어주셔서 감사합니다.
플러그인을 설치한 후 다음과 같은 오류가 발생했습니다:

오류
이 토론 포럼을 구동하는 소프트웨어에서 예상치 못한 문제가 발생했습니다. 불편을 드려 죄송합니다.
오류에 대한 상세 정보는 기록되었고, 자동 알림이 생성되었습니다. 확인하겠습니다.
추가 조치는 필요하지 않습니다. 그러나 오류 상태가 지속되면, 오류 재현 단계를 포함한 추가 세부 정보를 사이트의 피드백 카테고리에 토론 주제를 게시하여 제공할 수 있습니다.

안녕하세요, 로컬 개발 환경에서 실행 중이신가요? 그렇다면 개발 로그를 DM으로 보내 주시겠어요? 개발 환경이 올바르게 설정되어 있다면 이 플러그인은 정상적으로 작동할 거예요.

1개의 좋아요

문서를 읽었지만 이해가 잘 안 됩니다. 이 플러그인을 로컬 개발 환경에 설치하는 것부터 시작해서, 커스텀 필드를 단계별로 추가하는 방법까지 영상 튜토리얼을 만들어 주실 수 있을까요?

이 플러그인을 다루는 것이 너무 어렵습니다.

가능하다면, 필드 타입이 숫자인 경우 검색 기능을 업그레이드해 주실 수 있을까요?

이 플러그인을 위해 기부를 할 수 있으니, 꼭 도와주세요!

@angus 님이 정리해 주신 이 리소스는 처음에 생각했던 것보다 훨씬 더 유용합니다.

커스텀 필드를 추가하는 코드와 명확한 설명이 제공될 뿐만 아니라, 코드의 상당 부분이 플러그인에 바로 적용할 수 있습니다. 코드가 대부분 FIELD_NAME과 FIELD_VALUE와 같은 변수를 사용하며, 이 변수들은 플러그인의 config/settings.yml 파일에서 정의할 수 있기 때문입니다(또한 플러그인의 파일 구조가 @angus 님이 제공한 GitHub 코드와 동일한지 확인해야 합니다). 코드를 살펴보는 과정에서 이전에 보았지만 제대로 이해하지 못했던 Discourse의 함수와 메서드에 대한 이해도 한층 깊어졌습니다.

지금까지 코드는 주제(topic)의 커스텀 필드를 생성하고 저장하는 데 매우 잘 작동합니다. 다만 두 가지 질문이 떠오릅니다:

  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)는 미래에 이와 유사한 것을 계획하고 있지만, 현재로서는 코드와 단계 설명만 제공됩니다. 특정 문제에 막혀 있다면 Development 채널에 게시물을 작성하고 문제를 상세히 설명해 주세요.

토픽 목록에서 필드를 사용하는 경우 필드를 미리 로드하는 방법을 보여주는 하위 단계를 토픽 커스텀 필드 플러그인에 추가했습니다.

표시되어야 할 카테고리를 식별하기 위해 카테고리 커스텀 필드가 필요합니다. 이 플러그인에서는 카테고리 커스텀 필드에도 동일한 처리를 적용했으며, 동일한 단계별 설명을 포함했습니다:

이 두 가지 교육용 플러그인을 조합한다고 해서 목표에 완전히 도달하는 것은 아니지만, 이어서 진행해 보실 수 있을지 확인해 보세요.

2개의 좋아요

정말 훌륭하네요, @angus. 정말 감사합니다.

비디오가 항상 좋은 건 맞습니다. 가능한 한 단순하게 만드는 것에 전적으로 찬성하지만, @angus가 정리해 준 이 리소스로부터 주요 값을 가져오는 데 비디오가 필수적이라고 생각하지는 않습니다. 이 리소스는 해당 리소스가 다루는 특정 목표(동작하는 토픽 커스텀 필드 또는 카테고리 커스텀 필드)를 달성하는 데 필요한 코드를 제공합니다. 비디오는 아마 @angus나 다른 누군가가 리소스를 구현하는 과정을 설명하는 내용이 될 텐데, 그건 단순한 작업이라 여기에서 바로 정리해 드릴 수 있을 것 같습니다.

분명히 말씀드리면, 이 리소스는 포럼을 커스터마이징하기 위해 사이트에 플러그 앤 플레이로 추가하는 플러그인이 아닙니다. 대신, 이 리소스는 플러그인에서 자체 커스텀 필드를 코딩하는 데 필요한 이해를 효율적으로 제공합니다.


제가 이 리소스를 사용한 방법은 다음과 같습니다:

config/settings에 원하는 필드의 이름과 유형을 추가해야 합니다. 이 리소스의 코드는 거기에서 정의된 변수를 사용합니다. 따라서 이후에 자신의 플러그인에서 코드를 작동시키기 위해 코드를 많이 커스터마이징할 필요가 없습니다. plugin.rb 및 기타 위치의 변수는 config/settings를 참조하므로, 그렇게 하면 작동할 것입니다.

config/settings를 업데이트한 후에는 코드를 따라가며 플러그인에 추가하기만 하면 됩니다:

  • plugin.rb의 코드로 시작하여, 커스텀 필드를 생성하기 위해 자신의 플러그인 plugin.rb에 해당 코드를 추가합니다.

  • 다음으로 initializer (assets/javascripts/discourse/[custom-field-initiliazer])로 가서 커스텀 필드를 초기화하고 서버에 저장할 수 있게 하는 코드를 가져옵니다.

  • 다음으로, 사용자가(또는 앱이 필드를 자동으로 추가하는 경우 앱이) 커스텀 필드의 값을 입력할 뷰 레이어의 양식을 여기 (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개의 좋아요

토픽 커스텀 필드 예제를 따라 하다 보니 composer에서 이상한 동작을 겪고 있습니다.

“토픽 생성” 버튼을 누를 때(예: 카테고리 표시 페이지—but 사이트 어디든) 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로, 이를 통해 필드를 추가할 수 있는 UI를 제공합니다. 더 세밀한 제어가 필요하면, 과정은 토픽과 카테고리와 매우 유사하지만, 사용자 필드의 경우 실제 프론트엔드 요소가 필요하지 않습니다.

실제로 저희(Pavilion)는 커스텀 필드 플러그인(WordPress의 ACF와 유사한 형태)을 만들 계획을 가지고 있으며, 초기에는 Custom Wizard 플러그인의 커스텀 필드 관리 인터페이스와 다소 비슷해 보일 것입니다.

실제로 일부 사용자는 이미 Custom Wizard 플러그인을 커스텀 필드 매니저로 사용하고 있습니다. 이 플러그인은 인스턴스의 모든 커스텀 필드(출처와 무관하게)를 나열하고, 이를 지원하는 모든 모델에 어떤 유형의 필드든 추가할 수 있게 해줍니다.

프론트엔드 지원(예: Topic Custom Field 교육용 플러그인에 표시된 것과 같은 것)을 추가하지는 않습니다. (그리고 그것은 custom wizard 플러그인의 컨텍스트에서는 작동하지 않을 것입니다.) 그래서 저희는 그것을 별도의 플러그인으로 분리하는 것을 고려하고 있습니다.

3개의 좋아요

@angus, 이 기능은 정말 좋아할 것 같습니다.

특히 프론트엔드 지원을 추가한다면 더 좋습니다.

관리자가 다양한 클래스에 커스텀 필드를 쉽게 추가할 수 있고, 사용자가 이를 채울 수 있으며(예: 주제, 게시글, 사용자 프로필 등), 프론트엔드에서 이를 표시할 수 있는 방법을 원합니다.

현재 커스텀 사용자 필드에서 제가 얻지 못하는 주요 사항은 다양한 필드 유형입니다. 현재는 4가지로 제한된 것 같고, Custom Wizards 플러그인에서 사용 가능한 옵션들을 가지고 싶습니다.

이상적으로는, 다양한 유형의 많은 커스텀 필드를 갖춘 상당히 고급 수준의 검색/필터/정렬 가능한 사용자 디렉토리를 구축하고 싶습니다. 당분간 Custom Wizards로 실험해 보고, 여러분이 Custom Fields 플러그인에 투자하기를 희망합니다.

감사합니다!

@angus

우선 이 플러그인을 만들어 주셔서 정말 감사합니다.

여러 개의 커스텀 필드를 처리하기 위해 이 플러그인(커스텀 필드를 토픽으로 연결하는 방식)의 작동하는 예시가 있을까요? 저는 커스텀 필드 하나를 성공적으로 추가했고, 몇 가지 수정도 문제없이 진행했습니다.

코드를 복제하고 수정하거나, 추가 플러그인을 넣는 등 여러 가지를 시도해 보았지만 쉽지 않았습니다.

혹시 코드 저장소나 예시를 공유해 주실 수 있는 분이 계신가요? 도움이 정말 많이 필요하니 조언을 부탁드립니다.

1개의 좋아요

Hey @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)

    # 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 파일에 대해 어떤 조언을 주실 수 있을까요? 새로운 주제를 생성할 때 3개 필드 중 1개, 즉 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개의 좋아요