Add ability to translate server rules (#34494)
This commit is contained in:
parent
977164decc
commit
8c51a8ba94
17 changed files with 149 additions and 20 deletions
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue