From 8c51a8ba94a16ac94c865e37559a6e01e30884a9 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 21 May 2025 13:54:12 +0200 Subject: [PATCH] Add ability to translate server rules (#34494) --- app/controllers/admin/rules_controller.rb | 5 ++-- .../api/v1/instances/rules_controller.rb | 2 +- .../auth/registrations_controller.rb | 2 +- .../mastodon/features/about/index.jsx | 19 ++++++++----- .../mastodon/features/report/rules.jsx | 6 +++-- app/models/rule.rb | 8 ++++++ app/models/rule_translation.rb | 20 ++++++++++++++ app/presenters/instance_presenter.rb | 2 +- app/serializers/rest/rule_serializer.rb | 8 +++++- .../admin/rules/_translation_fields.html.haml | 27 +++++++++++++++++++ app/views/admin/rules/edit.html.haml | 21 +++++++++++++++ app/views/auth/registrations/rules.html.haml | 5 ++-- config/locales/en.yml | 4 +++ ...20250520204643_create_rule_translations.rb | 16 +++++++++++ db/schema.rb | 13 ++++++++- .../rule_translation_fabricator.rb | 8 ++++++ spec/serializers/rest/rule_serializer_spec.rb | 3 ++- 17 files changed, 149 insertions(+), 20 deletions(-) create mode 100644 app/models/rule_translation.rb create mode 100644 app/views/admin/rules/_translation_fields.html.haml create mode 100644 db/migrate/20250520204643_create_rule_translations.rb create mode 100644 spec/fabricators/rule_translation_fabricator.rb diff --git a/app/controllers/admin/rules_controller.rb b/app/controllers/admin/rules_controller.rb index b9331e7e9..58d52e629 100644 --- a/app/controllers/admin/rules_controller.rb +++ b/app/controllers/admin/rules_controller.rb @@ -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 diff --git a/app/controllers/api/v1/instances/rules_controller.rb b/app/controllers/api/v1/instances/rules_controller.rb index 3930eec0d..2b6e53487 100644 --- a/app/controllers/api/v1/instances/rules_controller.rb +++ b/app/controllers/api/v1/instances/rules_controller.rb @@ -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 diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 0b6f5b3af..973724cf7 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -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! diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 34e84506f..f2ea16a95 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -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 {

) : (
    - {server.get('rules').map(rule => ( -
  1. -
    {rule.get('text')}
    - {rule.get('hint').length > 0 && (
    {rule.get('hint')}
    )} -
  2. - ))} + {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 ( +
  3. +
    {text}
    + {hint.length > 0 && (
    {hint}
    )} +
  4. + )})}
))} diff --git a/app/javascript/mastodon/features/report/rules.jsx b/app/javascript/mastodon/features/report/rules.jsx index 621f140ad..dff376937 100644 --- a/app/javascript/mastodon/features/report/rules.jsx +++ b/app/javascript/mastodon/features/report/rules.jsx @@ -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 /> ))} diff --git a/app/models/rule.rb b/app/models/rule.rb index 3033a2b03..8f36f11ab 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -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 diff --git a/app/models/rule_translation.rb b/app/models/rule_translation.rb new file mode 100644 index 000000000..99991b2ee --- /dev/null +++ b/app/models/rule_translation.rb @@ -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 diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb index 92415a690..6923f565e 100644 --- a/app/presenters/instance_presenter.rb +++ b/app/presenters/instance_presenter.rb @@ -47,7 +47,7 @@ class InstancePresenter < ActiveModelSerializers::Model end def rules - Rule.ordered + Rule.ordered.includes(:translations) end def user_count diff --git a/app/serializers/rest/rule_serializer.rb b/app/serializers/rest/rule_serializer.rb index 9e2bcda15..3ce2d02e6 100644 --- a/app/serializers/rest/rule_serializer.rb +++ b/app/serializers/rest/rule_serializer.rb @@ -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 diff --git a/app/views/admin/rules/_translation_fields.html.haml b/app/views/admin/rules/_translation_fields.html.haml new file mode 100644 index 000000000..bf8d81722 --- /dev/null +++ b/app/views/admin/rules/_translation_fields.html.haml @@ -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 + = 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 diff --git a/app/views/admin/rules/edit.html.haml b/app/views/admin/rules/edit.html.haml index 9e3c91581..b64a27d75 100644 --- a/app/views/admin/rules/edit.html.haml +++ b/app/views/admin/rules/edit.html.haml @@ -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 diff --git a/app/views/auth/registrations/rules.html.haml b/app/views/auth/registrations/rules.html.haml index 4b0159e86..59e7c9072 100644 --- a/app/views/auth/registrations/rules.html.haml +++ b/app/views/auth/registrations/rules.html.haml @@ -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) diff --git a/config/locales/en.yml b/config/locales/en.yml index dad46d9d5..2ab8f015d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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. Make it easier to see your server's rules at a glance by providing them in a flat bullet point list. 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 diff --git a/db/migrate/20250520204643_create_rule_translations.rb b/db/migrate/20250520204643_create_rule_translations.rb new file mode 100644 index 000000000..cf696e769 --- /dev/null +++ b/db/migrate/20250520204643_create_rule_translations.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 6cb2a667a..6c97151bb 100644 --- a/db/schema.rb +++ b/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 diff --git a/spec/fabricators/rule_translation_fabricator.rb b/spec/fabricators/rule_translation_fabricator.rb new file mode 100644 index 000000000..de29e47e7 --- /dev/null +++ b/spec/fabricators/rule_translation_fabricator.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +Fabricator(:rule_translation) do + text 'MyText' + hint 'MyText' + language 'en' + rule { Fabricate.build(:rule) } +end diff --git a/spec/serializers/rest/rule_serializer_spec.rb b/spec/serializers/rest/rule_serializer_spec.rb index 4d801e77d..9d2889c9f 100644 --- a/spec/serializers/rest/rule_serializer_spec.rb +++ b/spec/serializers/rest/rule_serializer_spec.rb @@ -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