Add ability to translate server rules (#34494)
This commit is contained in:
		@@ -7,7 +7,7 @@ module Admin
 | 
			
		||||
    def index
 | 
			
		||||
      authorize :rule, :index?
 | 
			
		||||
 | 
			
		||||
      @rules = Rule.ordered
 | 
			
		||||
      @rules = Rule.ordered.includes(:translations)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def new
 | 
			
		||||
@@ -27,7 +27,6 @@ module Admin
 | 
			
		||||
      if @rule.save
 | 
			
		||||
        redirect_to admin_rules_path
 | 
			
		||||
      else
 | 
			
		||||
        @rules = Rule.ordered
 | 
			
		||||
        render :new
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
@@ -74,7 +73,7 @@ module Admin
 | 
			
		||||
 | 
			
		||||
    def resource_params
 | 
			
		||||
      params
 | 
			
		||||
        .expect(rule: [:text, :hint, :priority])
 | 
			
		||||
        .expect(rule: [:text, :hint, :priority, translations_attributes: [[:id, :language, :text, :hint, :_destroy]]])
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
@@ -18,6 +18,6 @@ class Api::V1::Instances::RulesController < Api::V1::Instances::BaseController
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def set_rules
 | 
			
		||||
    @rules = Rule.ordered
 | 
			
		||||
    @rules = Rule.ordered.includes(:translations)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
@@ -126,7 +126,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def set_rules
 | 
			
		||||
    @rules = Rule.ordered
 | 
			
		||||
    @rules = Rule.ordered.includes(:translations)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def require_rules_acceptance!
 | 
			
		||||
 
 | 
			
		||||
@@ -44,6 +44,7 @@ const severityMessages = {
 | 
			
		||||
 | 
			
		||||
const mapStateToProps = state => ({
 | 
			
		||||
  server: state.getIn(['server', 'server']),
 | 
			
		||||
  locale: state.getIn(['meta', 'locale']),
 | 
			
		||||
  extendedDescription: state.getIn(['server', 'extendedDescription']),
 | 
			
		||||
  domainBlocks: state.getIn(['server', 'domainBlocks']),
 | 
			
		||||
});
 | 
			
		||||
@@ -91,6 +92,7 @@ class About extends PureComponent {
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    server: ImmutablePropTypes.map,
 | 
			
		||||
    locale: ImmutablePropTypes.string,
 | 
			
		||||
    extendedDescription: ImmutablePropTypes.map,
 | 
			
		||||
    domainBlocks: ImmutablePropTypes.contains({
 | 
			
		||||
      isLoading: PropTypes.bool,
 | 
			
		||||
@@ -114,7 +116,7 @@ class About extends PureComponent {
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props;
 | 
			
		||||
    const { multiColumn, intl, server, extendedDescription, domainBlocks, locale } = this.props;
 | 
			
		||||
    const isLoading = server.get('isLoading');
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
@@ -168,12 +170,15 @@ class About extends PureComponent {
 | 
			
		||||
              <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
 | 
			
		||||
            ) : (
 | 
			
		||||
              <ol className='rules-list'>
 | 
			
		||||
                {server.get('rules').map(rule => (
 | 
			
		||||
                  <li key={rule.get('id')}>
 | 
			
		||||
                    <div className='rules-list__text'>{rule.get('text')}</div>
 | 
			
		||||
                    {rule.get('hint').length > 0 && (<div className='rules-list__hint'>{rule.get('hint')}</div>)}
 | 
			
		||||
                  </li>
 | 
			
		||||
                ))}
 | 
			
		||||
                {server.get('rules').map(rule => {
 | 
			
		||||
                  const text = rule.getIn(['translations', locale, 'text']) || rule.get('text');
 | 
			
		||||
                  const hint = rule.getIn(['translations', locale, 'hint']) || rule.get('hint');
 | 
			
		||||
                  return (
 | 
			
		||||
                    <li key={rule.get('id')}>
 | 
			
		||||
                      <div className='rules-list__text'>{text}</div>
 | 
			
		||||
                      {hint.length > 0 && (<div className='rules-list__hint'>{hint}</div>)}
 | 
			
		||||
                    </li>
 | 
			
		||||
                  )})}
 | 
			
		||||
              </ol>
 | 
			
		||||
            ))}
 | 
			
		||||
          </Section>
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,7 @@ import Option from './components/option';
 | 
			
		||||
 | 
			
		||||
const mapStateToProps = state => ({
 | 
			
		||||
  rules: state.getIn(['server', 'server', 'rules']),
 | 
			
		||||
  locale: state.getIn(['meta', 'locale']),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
class Rules extends PureComponent {
 | 
			
		||||
@@ -19,6 +20,7 @@ class Rules extends PureComponent {
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    onNextStep: PropTypes.func.isRequired,
 | 
			
		||||
    rules: ImmutablePropTypes.list,
 | 
			
		||||
    locale: PropTypes.string,
 | 
			
		||||
    selectedRuleIds: ImmutablePropTypes.set.isRequired,
 | 
			
		||||
    onToggle: PropTypes.func.isRequired,
 | 
			
		||||
  };
 | 
			
		||||
@@ -34,7 +36,7 @@ class Rules extends PureComponent {
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { rules, selectedRuleIds } = this.props;
 | 
			
		||||
    const { rules, locale, selectedRuleIds } = this.props;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <>
 | 
			
		||||
@@ -49,7 +51,7 @@ class Rules extends PureComponent {
 | 
			
		||||
              value={item.get('id')}
 | 
			
		||||
              checked={selectedRuleIds.includes(item.get('id'))}
 | 
			
		||||
              onToggle={this.handleRulesToggle}
 | 
			
		||||
              label={item.get('text')}
 | 
			
		||||
              label={item.getIn(['translations', locale, 'text']) || item.get('text')}
 | 
			
		||||
              multiple
 | 
			
		||||
            />
 | 
			
		||||
          ))}
 | 
			
		||||
 
 | 
			
		||||
@@ -19,6 +19,9 @@ class Rule < ApplicationRecord
 | 
			
		||||
 | 
			
		||||
  self.discard_column = :deleted_at
 | 
			
		||||
 | 
			
		||||
  has_many :translations, inverse_of: :rule, class_name: 'RuleTranslation', dependent: :destroy
 | 
			
		||||
  accepts_nested_attributes_for :translations, reject_if: :all_blank, allow_destroy: true
 | 
			
		||||
 | 
			
		||||
  validates :text, presence: true, length: { maximum: TEXT_SIZE_LIMIT }
 | 
			
		||||
 | 
			
		||||
  scope :ordered, -> { kept.order(priority: :asc, id: :asc) }
 | 
			
		||||
@@ -36,4 +39,9 @@ class Rule < ApplicationRecord
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def translation_for(locale)
 | 
			
		||||
    @cached_translations ||= {}
 | 
			
		||||
    @cached_translations[locale] ||= translations.find_by(language: locale) || RuleTranslation.new(language: locale, text: text, hint: hint)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								app/models/rule_translation.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								app/models/rule_translation.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
# == Schema Information
 | 
			
		||||
#
 | 
			
		||||
# Table name: rule_translations
 | 
			
		||||
#
 | 
			
		||||
#  id         :bigint(8)        not null, primary key
 | 
			
		||||
#  hint       :text             default(""), not null
 | 
			
		||||
#  language   :string           not null
 | 
			
		||||
#  text       :text             default(""), not null
 | 
			
		||||
#  created_at :datetime         not null
 | 
			
		||||
#  updated_at :datetime         not null
 | 
			
		||||
#  rule_id    :bigint(8)        not null
 | 
			
		||||
#
 | 
			
		||||
class RuleTranslation < ApplicationRecord
 | 
			
		||||
  belongs_to :rule
 | 
			
		||||
 | 
			
		||||
  validates :language, presence: true, uniqueness: { scope: :rule_id }
 | 
			
		||||
  validates :text, presence: true, length: { maximum: Rule::TEXT_SIZE_LIMIT }
 | 
			
		||||
end
 | 
			
		||||
@@ -47,7 +47,7 @@ class InstancePresenter < ActiveModelSerializers::Model
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def rules
 | 
			
		||||
    Rule.ordered
 | 
			
		||||
    Rule.ordered.includes(:translations)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def user_count
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,15 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class REST::RuleSerializer < ActiveModel::Serializer
 | 
			
		||||
  attributes :id, :text, :hint
 | 
			
		||||
  attributes :id, :text, :hint, :translations
 | 
			
		||||
 | 
			
		||||
  def id
 | 
			
		||||
    object.id.to_s
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def translations
 | 
			
		||||
    object.translations.to_h do |translation|
 | 
			
		||||
      [translation.language, { text: translation.text, hint: translation.hint }]
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										27
									
								
								app/views/admin/rules/_translation_fields.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								app/views/admin/rules/_translation_fields.html.haml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
			
		||||
%tr.nested-fields
 | 
			
		||||
  %td
 | 
			
		||||
    .fields-row
 | 
			
		||||
      .fields-row__column.fields-group
 | 
			
		||||
        = f.input :language,
 | 
			
		||||
                  collection: ui_languages,
 | 
			
		||||
                  include_blank: false,
 | 
			
		||||
                  label_method: ->(locale) { native_locale_name(locale) }
 | 
			
		||||
 | 
			
		||||
      .fields-row__column.fields-group
 | 
			
		||||
        = f.hidden_field :id if f.object&.persisted? # Required so Rails doesn't put the field outside of the <tr/>
 | 
			
		||||
        = link_to_remove_association(f, class: 'table-action-link') do
 | 
			
		||||
          = safe_join([material_symbol('close'), t('filters.index.delete')])
 | 
			
		||||
 | 
			
		||||
    .fields-group
 | 
			
		||||
      = f.input :text,
 | 
			
		||||
                label: I18n.t('simple_form.labels.rule.text'),
 | 
			
		||||
                hint: I18n.t('simple_form.hints.rule.text'),
 | 
			
		||||
                input_html: { lang: f.object&.language },
 | 
			
		||||
                wrapper: :with_block_label
 | 
			
		||||
 | 
			
		||||
    .fields-group
 | 
			
		||||
      = f.input :hint,
 | 
			
		||||
                label: I18n.t('simple_form.labels.rule.hint'),
 | 
			
		||||
                hint: I18n.t('simple_form.hints.rule.hint'),
 | 
			
		||||
                input_html: { lang: f.object&.language },
 | 
			
		||||
                wrapper: :with_block_label
 | 
			
		||||
@@ -6,5 +6,26 @@
 | 
			
		||||
 | 
			
		||||
  = render form
 | 
			
		||||
 | 
			
		||||
  %hr.spacer/
 | 
			
		||||
 | 
			
		||||
  %h4= t('admin.rules.translations')
 | 
			
		||||
 | 
			
		||||
  %p.hint= t('admin.rules.translations_explanation')
 | 
			
		||||
 | 
			
		||||
  .table-wrapper
 | 
			
		||||
    %table.table.keywords-table
 | 
			
		||||
      %thead
 | 
			
		||||
        %tr
 | 
			
		||||
          %th= t('admin.rules.translation')
 | 
			
		||||
          %th
 | 
			
		||||
      %tbody
 | 
			
		||||
        = form.simple_fields_for :translations do |translation|
 | 
			
		||||
          = render 'translation_fields', f: translation
 | 
			
		||||
      %tfoot
 | 
			
		||||
        %tr
 | 
			
		||||
          %td{ colspan: 3 }
 | 
			
		||||
            = link_to_add_association form, :translations, class: 'table-action-link', partial: 'translation_fields', 'data-association-insertion-node': '.keywords-table tbody', 'data-association-insertion-method': 'append' do
 | 
			
		||||
              = safe_join([material_symbol('add'), t('admin.rules.add_translation')])
 | 
			
		||||
 | 
			
		||||
  .actions
 | 
			
		||||
    = form.button :button, t('generic.save_changes'), type: :submit
 | 
			
		||||
 
 | 
			
		||||
@@ -18,10 +18,11 @@
 | 
			
		||||
 | 
			
		||||
  %ol.rules-list
 | 
			
		||||
    - @rules.each do |rule|
 | 
			
		||||
      - translation = rule.translation_for(I18n.locale.to_s)
 | 
			
		||||
      %li
 | 
			
		||||
        %button{ type: 'button', aria: { expanded: 'false' } }
 | 
			
		||||
          .rules-list__text= rule.text
 | 
			
		||||
          .rules-list__hint= rule.hint
 | 
			
		||||
          .rules-list__text= translation.text
 | 
			
		||||
          .rules-list__hint= translation.hint
 | 
			
		||||
 | 
			
		||||
  .stacked-actions
 | 
			
		||||
    - accept_path = @invite_code.present? ? public_invite_url(invite_code: @invite_code, accept: @accept_token) : new_user_registration_path(accept: @accept_token)
 | 
			
		||||
 
 | 
			
		||||
@@ -786,6 +786,7 @@ en:
 | 
			
		||||
      title: Roles
 | 
			
		||||
    rules:
 | 
			
		||||
      add_new: Add rule
 | 
			
		||||
      add_translation: Add translation
 | 
			
		||||
      delete: Delete
 | 
			
		||||
      description_html: While most claim to have read and agree to the terms of service, usually people do not read through until after a problem arises. <strong>Make it easier to see your server's rules at a glance by providing them in a flat bullet point list.</strong> Try to keep individual rules short and simple, but try not to split them up into many separate items either.
 | 
			
		||||
      edit: Edit rule
 | 
			
		||||
@@ -793,6 +794,9 @@ en:
 | 
			
		||||
      move_down: Move down
 | 
			
		||||
      move_up: Move up
 | 
			
		||||
      title: Server rules
 | 
			
		||||
      translation: Translation
 | 
			
		||||
      translations: Translations
 | 
			
		||||
      translations_explanation: You can optionally add translations for the rules. The default value will be shown if no translated version is available. Please always ensure any provided translation is in sync with the default value.
 | 
			
		||||
    settings:
 | 
			
		||||
      about:
 | 
			
		||||
        manage_rules: Manage server rules
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										16
									
								
								db/migrate/20250520204643_create_rule_translations.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								db/migrate/20250520204643_create_rule_translations.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class CreateRuleTranslations < ActiveRecord::Migration[8.0]
 | 
			
		||||
  def change
 | 
			
		||||
    create_table :rule_translations do |t|
 | 
			
		||||
      t.text :text, null: false, default: ''
 | 
			
		||||
      t.text :hint, null: false, default: ''
 | 
			
		||||
      t.string :language, null: false
 | 
			
		||||
      t.references :rule, null: false, foreign_key: { on_delete: :cascade }, index: false
 | 
			
		||||
 | 
			
		||||
      t.timestamps
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    add_index :rule_translations, [:rule_id, :language], unique: true
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										13
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								db/schema.rb
									
									
									
									
									
								
							@@ -10,7 +10,7 @@
 | 
			
		||||
#
 | 
			
		||||
# It's strongly recommended that you check this file into your version control system.
 | 
			
		||||
 | 
			
		||||
ActiveRecord::Schema[8.0].define(version: 2025_05_20_192024) do
 | 
			
		||||
ActiveRecord::Schema[8.0].define(version: 2025_05_20_204643) do
 | 
			
		||||
  # These are extensions that must be enabled in order to support this database
 | 
			
		||||
  enable_extension "pg_catalog.plpgsql"
 | 
			
		||||
 | 
			
		||||
@@ -962,6 +962,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_20_192024) do
 | 
			
		||||
    t.index ["target_account_id"], name: "index_reports_on_target_account_id"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  create_table "rule_translations", force: :cascade do |t|
 | 
			
		||||
    t.text "text", default: "", null: false
 | 
			
		||||
    t.text "hint", default: "", null: false
 | 
			
		||||
    t.string "language", null: false
 | 
			
		||||
    t.bigint "rule_id", null: false
 | 
			
		||||
    t.datetime "created_at", null: false
 | 
			
		||||
    t.datetime "updated_at", null: false
 | 
			
		||||
    t.index ["rule_id", "language"], name: "index_rule_translations_on_rule_id_and_language", unique: true
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  create_table "rules", force: :cascade do |t|
 | 
			
		||||
    t.integer "priority", default: 0, null: false
 | 
			
		||||
    t.datetime "deleted_at", precision: nil
 | 
			
		||||
@@ -1406,6 +1416,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_20_192024) do
 | 
			
		||||
  add_foreign_key "reports", "accounts", column: "target_account_id", name: "fk_eb37af34f0", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "reports", "accounts", name: "fk_4b81f7522c", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "reports", "oauth_applications", column: "application_id", on_delete: :nullify
 | 
			
		||||
  add_foreign_key "rule_translations", "rules", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "scheduled_statuses", "accounts", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										8
									
								
								spec/fabricators/rule_translation_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								spec/fabricators/rule_translation_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
Fabricator(:rule_translation) do
 | 
			
		||||
  text     'MyText'
 | 
			
		||||
  hint     'MyText'
 | 
			
		||||
  language 'en'
 | 
			
		||||
  rule     { Fabricate.build(:rule) }
 | 
			
		||||
end
 | 
			
		||||
@@ -11,7 +11,8 @@ RSpec.describe REST::RuleSerializer do
 | 
			
		||||
    it 'returns expected values' do
 | 
			
		||||
      expect(subject)
 | 
			
		||||
        .to include(
 | 
			
		||||
          'id' => be_a(String).and(eq('123'))
 | 
			
		||||
          'id' => be_a(String).and(eq('123')),
 | 
			
		||||
          'translations' => be_a(Hash)
 | 
			
		||||
        )
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user