This commit is contained in:
Claire 2025-04-23 08:06:29 +00:00 committed by GitHub
commit b58ce8c360
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 145 additions and 20 deletions

View File

@ -7,7 +7,7 @@ module Admin
def index def index
authorize :rule, :index? authorize :rule, :index?
@rules = Rule.ordered @rules = Rule.ordered.includes(:translations)
@rule = Rule.new @rule = Rule.new
end end
@ -23,7 +23,7 @@ module Admin
if @rule.save if @rule.save
redirect_to admin_rules_path redirect_to admin_rules_path
else else
@rules = Rule.ordered @rules = Rule.ordered.includes(:translations)
render :index render :index
end end
end end
@ -54,7 +54,7 @@ module Admin
def resource_params def resource_params
params params
.expect(rule: [:text, :hint, :priority]) .expect(rule: [:text, :hint, :priority, translations_attributes: [[:id, :language, :text, :hint, :_destroy]]])
end end
end end
end end

View File

@ -18,6 +18,6 @@ class Api::V1::Instances::RulesController < Api::V1::Instances::BaseController
private private
def set_rules def set_rules
@rules = Rule.ordered @rules = Rule.ordered.includes(:translations)
end end
end end

View File

@ -126,7 +126,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end end
def set_rules def set_rules
@rules = Rule.ordered @rules = Rule.ordered.includes(:translations)
end end
def require_rules_acceptance! def require_rules_acceptance!

View File

@ -44,6 +44,7 @@ const severityMessages = {
const mapStateToProps = state => ({ const mapStateToProps = state => ({
server: state.getIn(['server', 'server']), server: state.getIn(['server', 'server']),
locale: state.getIn(['meta', 'locale']),
extendedDescription: state.getIn(['server', 'extendedDescription']), extendedDescription: state.getIn(['server', 'extendedDescription']),
domainBlocks: state.getIn(['server', 'domainBlocks']), domainBlocks: state.getIn(['server', 'domainBlocks']),
}); });
@ -91,6 +92,7 @@ class About extends PureComponent {
static propTypes = { static propTypes = {
server: ImmutablePropTypes.map, server: ImmutablePropTypes.map,
locale: ImmutablePropTypes.string,
extendedDescription: ImmutablePropTypes.map, extendedDescription: ImmutablePropTypes.map,
domainBlocks: ImmutablePropTypes.contains({ domainBlocks: ImmutablePropTypes.contains({
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
@ -114,7 +116,7 @@ class About extends PureComponent {
}; };
render () { render () {
const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props; const { multiColumn, intl, server, extendedDescription, domainBlocks, locale } = this.props;
const isLoading = server.get('isLoading'); const isLoading = server.get('isLoading');
return ( 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> <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
) : ( ) : (
<ol className='rules-list'> <ol className='rules-list'>
{server.get('rules').map(rule => ( {server.get('rules').map(rule => {
<li key={rule.get('id')}> const text = rule.getIn(['translations', locale, 'text']) || rule.get('text');
<div className='rules-list__text'>{rule.get('text')}</div> const hint = rule.getIn(['translations', locale, 'hint']) || rule.get('hint');
{rule.get('hint').length > 0 && (<div className='rules-list__hint'>{rule.get('hint')}</div>)} return (
</li> <li key={rule.get('id')}>
))} <div className='rules-list__text'>{text}</div>
{hint.length > 0 && (<div className='rules-list__hint'>{hint}</div>)}
</li>
)})}
</ol> </ol>
))} ))}
</Section> </Section>

View File

@ -12,6 +12,7 @@ import Option from './components/option';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
rules: state.getIn(['server', 'server', 'rules']), rules: state.getIn(['server', 'server', 'rules']),
locale: state.getIn(['meta', 'locale']),
}); });
class Rules extends PureComponent { class Rules extends PureComponent {
@ -19,6 +20,7 @@ class Rules extends PureComponent {
static propTypes = { static propTypes = {
onNextStep: PropTypes.func.isRequired, onNextStep: PropTypes.func.isRequired,
rules: ImmutablePropTypes.list, rules: ImmutablePropTypes.list,
locale: PropTypes.string,
selectedRuleIds: ImmutablePropTypes.set.isRequired, selectedRuleIds: ImmutablePropTypes.set.isRequired,
onToggle: PropTypes.func.isRequired, onToggle: PropTypes.func.isRequired,
}; };
@ -34,7 +36,7 @@ class Rules extends PureComponent {
}; };
render () { render () {
const { rules, selectedRuleIds } = this.props; const { rules, locale, selectedRuleIds } = this.props;
return ( return (
<> <>
@ -49,7 +51,7 @@ class Rules extends PureComponent {
value={item.get('id')} value={item.get('id')}
checked={selectedRuleIds.includes(item.get('id'))} checked={selectedRuleIds.includes(item.get('id'))}
onToggle={this.handleRulesToggle} onToggle={this.handleRulesToggle}
label={item.get('text')} label={item.getIn(['translations', locale, 'text']) || item.get('text')}
multiple multiple
/> />
))} ))}

View File

@ -19,6 +19,9 @@ class Rule < ApplicationRecord
self.discard_column = :deleted_at 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 } validates :text, presence: true, length: { maximum: TEXT_SIZE_LIMIT }
scope :ordered, -> { kept.order(priority: :asc, id: :asc) } scope :ordered, -> { kept.order(priority: :asc, id: :asc) }

View 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

View File

@ -47,7 +47,7 @@ class InstancePresenter < ActiveModelSerializers::Model
end end
def rules def rules
Rule.ordered Rule.ordered.includes(:translations)
end end
def user_count def user_count

View File

@ -1,9 +1,15 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::RuleSerializer < ActiveModel::Serializer class REST::RuleSerializer < ActiveModel::Serializer
attributes :id, :text, :hint attributes :id, :text, :hint, :translations
def id def id
object.id.to_s object.id.to_s
end end
def translations
object.translations.to_h do |translation|
[translation.language, { text: translation.text, hint: translation.hint }]
end
end
end end

View 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

View File

@ -6,5 +6,26 @@
= render form = 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 .actions
= form.button :button, t('generic.save_changes'), type: :submit = form.button :button, t('generic.save_changes'), type: :submit

View File

@ -18,10 +18,11 @@
%ol.rules-list %ol.rules-list
- @rules.each do |rule| - @rules.each do |rule|
- translation = rule.translations.find { |translation| translation.language == I18n.locale.to_s }
%li %li
%button{ type: 'button', aria: { expanded: 'false' } } %button{ type: 'button', aria: { expanded: 'false' } }
.rules-list__text= rule.text .rules-list__text= translation&.text || rule.text
.rules-list__hint= rule.hint .rules-list__hint= translation&.hint || rule.hint
.stacked-actions .stacked-actions
- accept_path = @invite_code.present? ? public_invite_url(invite_code: @invite_code, accept: @accept_token) : new_user_registration_path(accept: @accept_token) - accept_path = @invite_code.present? ? public_invite_url(invite_code: @invite_code, accept: @accept_token) : new_user_registration_path(accept: @accept_token)

View File

@ -786,11 +786,15 @@ en:
title: Roles title: Roles
rules: rules:
add_new: Add rule add_new: Add rule
add_translation: Add translation
delete: Delete 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. 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 edit: Edit rule
empty: No server rules have been defined yet. empty: No server rules have been defined yet.
title: Server rules 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: settings:
about: about:
manage_rules: Manage server rules manage_rules: Manage server rules

View 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

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_04_11_095859) do ActiveRecord::Schema[8.0].define(version: 2025_04_17_154643) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@ -937,6 +937,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_11_095859) do
t.index ["target_account_id"], name: "index_reports_on_target_account_id" t.index ["target_account_id"], name: "index_reports_on_target_account_id"
end 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| create_table "rules", force: :cascade do |t|
t.integer "priority", default: 0, null: false t.integer "priority", default: 0, null: false
t.datetime "deleted_at", precision: nil t.datetime "deleted_at", precision: nil
@ -1380,6 +1390,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_11_095859) do
add_foreign_key "reports", "accounts", column: "target_account_id", name: "fk_eb37af34f0", on_delete: :cascade 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", "accounts", name: "fk_4b81f7522c", on_delete: :cascade
add_foreign_key "reports", "oauth_applications", column: "application_id", on_delete: :nullify 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 "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", "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 add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
Fabricator(:rule_translation) do
text 'MyText'
hint 'MyText'
language 'en'
rule { Fabricate.build(:rule) }
end

View File

@ -11,7 +11,8 @@ RSpec.describe REST::RuleSerializer do
it 'returns expected values' do it 'returns expected values' do
expect(subject) expect(subject)
.to include( .to include(
'id' => be_a(String).and(eq('123')) 'id' => be_a(String).and(eq('123')),
'translations' => be_a(Hash)
) )
end end
end end