Реализация валидации пользовательских полей

Это статья для будущих разработчиков, которым может понадобиться эта информация для создания собственного плагина, специально предназначенного для валидации пользовательских полей.

Статья написана на базе версии Discourse 3.6.0.beta3-latest (текущий коммит a7326abf15), поэтому, если что-то не будет работать, возможно, в коде ядра (Core) что-то изменилось.

Главная причина написания этой статьи — полное отсутствие информации о добавлении кастомной валидации для пользовательских полей.

Если коротко: мне пришлось добавить кастомную валидацию для одного из пользовательских полей, которое по сути является текстовым полем ввода. Основное требование заключалось в том, чтобы значение этого поля было уникальным. Будучи разработчиком на Python/PHP, реализовать это в используемых мной фреймворках/CMS довольно просто, но в случае с Discourse всё оказалось иначе. Дело не в специфике языка, а в том, что документации по этому вопросу нет, а найденные мной посты с похожими вопросами остались без ответов. После полутора недель исследований ядра я наконец добился ожидаемого результата и хочу поделиться этим с другими, так как это может кому-то помочь.

Итак, пользовательское поле, которое должно иметь уникальное значение.

Для решения этой задачи нам нужно создать кастомный плагин для Discourse. Основная причина этого в том, что нам нужно проверять значение в базе данных, что выполняется на бэкенде.

Вот структура проекта:

- my_custom_plugin
-- assets
--- javascripts
---- discourse
----- initializers
------ your-initializer-js-file.js
-- config
--- locales
---- server.en.yml
---- client.en.yml
--- settings.yml
-- plugin.rb

Теперь о главной проблеме, с которой я столкнулся: как вообще внедриться в пользовательские поля. После поиска в ядре я нашел API-метод addCustomUserFieldValidationCallback. Его можно найти в файле plugin-api.gjs в ядре. Это метод, который срабатывает после первой попытки отправки формы регистрации, а затем вызывается при каждом вводе символа в поле или любое другое пользовательское поле, что мне и требовалось.
В примере для этого метода показано, что он принимает функцию обратного вызова (callback), а аргумент, который он возвращает, — это сам объект userField, с которым вы работаете в данный момент.
Вот код для your-initializer-js-file.js (не полный, но достаточный):

import { apiInitializer } from "discourse/lib/api";
import { i18n } from "discourse-i18n";

export default apiInitializer("1.8.0", (api) => {
  // Список ID полей, требующих уникальной валидации
  const UNIQUE_FIELD_IDS = ["5"];

  // Клиентский callback для валидации
  api.addCustomUserFieldValidationCallback((userField) => {
    const fieldId = userField.field.id.toString();
    
    // Валидируем только поля из нашего списка уникальных полей
    if (UNIQUE_FIELD_IDS.includes(fieldId)) {
      // Проверяем, не пустое ли значение
      if (!userField.value || userField.value.trim() === "") {
        return null; // Оставляем обработку пустых значений стандартной валидации
      }
      
      const value = userField.value.trim();
      
      // Проверяем наличие значения в списке
      const unique = isValueUnique(fieldId, value);
      if (!unique) {
        return {
          failed: true,
          ok: false,
          reason: i18n("js.my_custom_plugin_text_validator.value_taken"),
          element: userField.field.element,
        };
      }
    }
    
    return null;
  });
});

Основные моменты для комментария здесь:

  • UNIQUE_FIELD_IDS используется для фильтрации полей, на которые должно влиять мое кастомное логическое решение. Число 5 — это ID поля, его можно проверить, открыв страницу редактирования пользовательского поля и посмотрев на URL.
  • isValueUnique — это кастомная функция, где содержится вся ваша логика валидации. Она должна быть объявлена вне api.addCustomUserFieldValidationCallback.

Поскольку нам нужно проверять значение в БД, это означает, что нам либо нужно сделать API-запрос к какому-то эндпоинту, который выполнит валидацию и вернет ответ о её результате, либо существует другой способ проверки без кастомного эндпоинта, который я пока не нашел. Существует модуль для некоторой валидации, но его валидация ограничена проверкой по REGEX прямо в JS, что мне не подходило.

Файл plugin.rb — это место, где мы указываем информацию о нашем кастомном плагине, его конфигурацию и всё, что можно добавить. В моем случае я зарегистрировал свой кастомный эндпоинт для валидации. Вот пример возможной валидации, у вас она может быть реализована иначе.

# frozen_string_literal: true

# name: my_custom_plugin
# about: Validates uniqueness of custom user fields
# version: 0.1
# authors: Yan R

enabled_site_setting :my_custom_plugin_enabled

after_initialize do
  # Закодированный список ID уникальных полей
  UNIQUE_FIELD_IDS = ["5"].freeze
  
  # Добавляем API-эндпоинты для валидации
  Discourse::Application.routes.append do
    get "/my_custom_plugin_endpoint_url/:field_id" => "my_custom_plugin_validation#validate"
  end

  class ::MyCustomPluginValidationController < ::ApplicationController
    skip_before_action :verify_authenticity_token
    skip_before_action :check_xhr
    skip_before_action :redirect_to_login_if_required

    def validate
      field_name = params[:field_name]
      field_value = params[:field_value]
      
      return render json: { valid: true } unless field_name.present? && field_value.present?
      return render json: { valid: true } unless field_name.start_with?("user_field_")
      
      # Нормализация значения: удаление пробелов и сравнение без учета регистра
      normalized_value = field_value.to_s.strip
      return render json: { valid: true } if normalized_value.empty?

      existing = UserCustomField
        .where(name: field_name)
        .where("LOWER(TRIM(value)) = LOWER(?)", normalized_value)
        .exists?

      render json: { valid: !existing }
    end

Теперь самая хитрая часть: вы можете использовать этот эндпоинт и делать fetch/ajax-запрос из вашего инициализатора, но это не сработает. Основная причина в том, что addCustomUserFieldValidationCallback не работает с асинхронными callback-функциями, поэтому вам нужно сделать запрос, который не будет асинхронным. Для этого я использовал XMLHttpRequest, например xhr.open('GET', '/my_custom_plugin_endpoint_url/${fieldId}', true);, где true отключает асинхронность.
Это начнет работать, но вы столкнетесь с проблемой: вы не сможете быстро печатать в поле ввода, потому что addCustomUserFieldValidationCallback срабатывает при каждой введенной букве и будет ждать корректного возврата, из-за чего ввод замораживается.

Я не показал это в примерах, но моим решением было получить список уникальных значений поля после загрузки страницы и использовать его для фильтрации значения прямо в JS, не делая дополнительных API-запросов. Вам просто нужно убедиться, что количество значений разумно, и защитить эндпоинт. В моем случае вместо списка значений я возвращал список хешей, поэтому в JS я преобразовывал введенное значение в хеш и сравнивал хеши. Помимо того, что это немного безопаснее, это также значительно уменьшает вес ответа, так что вы можете получить гораздо больше значений для сравнения.

Вот информация относительно папки config.

settings.yml:

plugins:
  my_custom_plugin_enabled:
    default: true
    client: true

server.en.yml

en:
  site_settings:
    my_custom_plugin_enabled: "Enable unique user fields validation"
  my_custom_plugin_text_validator:
    value_taken: "This value is already taken"

client.en.yml

en:
  js:
    my_custom_plugin_text_validator:
      value_taken: "This value is already taken"

Дополнение: Я не нашел информации о том, как добавить свой кастомный плагин, кроме как через app.yml, так как у меня настроен Docker. Я добавил свой кастомный плагин в папку/репозиторий Docker, содержащий настройку Discourse, и подключил его при операции пересборки в папку плагинов внутри контейнера.

Надеюсь, это поможет кому-то реализовать кастомную валидацию для пользовательских полей.

1 лайк