mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 00:54:21 +08:00
Previously, the custom sidebar section editor and the server assumed a section's base title and link names were written in the site's current *default* locale, so changing the default locale scrambled which language each stored value mapped to and offered the wrong set of translation targets ([meta](https://meta.discourse.org/t/408327)). This change keys the editor, the same-locale de-duplication and the collision cleanup off each record's own source locale — now surfaced as an editable "Section language" — and reorganises the translation UI around languages rather than fields. ### Notable behaviour changes - Links keep their own source language, so saving a section no longer relabels a link authored in a different one. - A localization colliding with its record's source is now destroyed instead of being left behind to shadow the base string. The collision is matched **exactly**, so a regional variant like `en_GB` under an `en` source is preserved. - Localization permissions are evaluated against the visibility being *submitted*, so making a section public and translating it in one save no longer 403s and loses the edit. - `create` now enforces localization permissions, which it previously skipped entirely — an admin could write localizations onto a private section. A non-admin submitting localizations on create now gets a `403` instead of a silent drop, matching `update`. - A submitted locale is validated at the request boundary: an unsupported or over-long value returns `400` rather than an unhandled `500`. A value already stored on the record is still accepted, so AI-detected locales outside the supported list stay editable. ### UI <img width="821" height="646" alt="2026-07-29 @ 14 56 10" src="https://github.com/user-attachments/assets/19dad480-9f77-4f5b-b1a6-ed29689839a8" /> <img width="820" height="666" alt="2026-07-29 @ 14 56 16" src="https://github.com/user-attachments/assets/a9377282-d4b2-43af-91a6-700e10c50ce6" /> Translations moved from inline per-field rows to a single "Manage translations" entry point opening a language-major panel — one group per language holding the section title and every link name. The old layout rendered `1 + links × languages` rows with an add button per field, which stopped being usable at three links and two languages. A blank field means "not translated yet" rather than invalid, and clearing a saved translation now asks for confirmation before removing it.
242 lines
7.8 KiB
Ruby
Vendored
242 lines
7.8 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
class SidebarSectionsController < ApplicationController
|
|
requires_login
|
|
before_action :check_access_if_public
|
|
|
|
def index
|
|
sections =
|
|
SidebarSection
|
|
.strict_loading
|
|
.includes(:localizations, sidebar_urls: :localizations)
|
|
.where("public OR user_id = ?", current_user.id)
|
|
.order("section_type IS NOT NULL DESC, public DESC, title ASC")
|
|
|
|
sections =
|
|
ActiveModel::ArraySerializer.new(
|
|
sections,
|
|
each_serializer: SidebarSectionSerializer,
|
|
scope: guardian,
|
|
root: "sidebar_sections",
|
|
)
|
|
|
|
render json: sections
|
|
end
|
|
|
|
def show
|
|
sidebar_section =
|
|
SidebarSection.includes(:localizations, sidebar_urls: :localizations).find(params[:id])
|
|
@guardian.ensure_can_edit!(sidebar_section)
|
|
|
|
render_serialized(sidebar_section, SidebarSectionEditSerializer, root: "sidebar_section")
|
|
rescue Discourse::InvalidAccess
|
|
render json: failed_json, status: :forbidden
|
|
end
|
|
|
|
def create
|
|
new_section = SidebarSection.new(public: submitted_public)
|
|
ensure_localization_params_allowed(new_section)
|
|
|
|
sidebar_section =
|
|
SidebarSection.create!(
|
|
section_params(new_section).merge(sidebar_urls_attributes: links_params(new_section)),
|
|
)
|
|
|
|
if sidebar_section.public?
|
|
StaffActionLogger.new(current_user).log_create_public_sidebar_section(sidebar_section)
|
|
MessageBus.publish("/refresh-sidebar-sections", nil)
|
|
Site.clear_anon_cache!
|
|
end
|
|
|
|
render_serialized(sidebar_section, SidebarSectionSerializer)
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
render_json_error(e.record.errors.full_messages.first)
|
|
rescue ActiveRecord::NestedAttributes::TooManyRecords => e
|
|
render_json_error(e.message)
|
|
rescue Discourse::InvalidAccess
|
|
render json: failed_json, status: :forbidden
|
|
end
|
|
|
|
def update
|
|
sidebar_section = SidebarSection.find(params[:id])
|
|
@guardian.ensure_can_edit!(sidebar_section)
|
|
ensure_localization_params_allowed(sidebar_section)
|
|
permitted_section_params = section_params(sidebar_section)
|
|
permitted_links_params = links_params(sidebar_section)
|
|
|
|
SidebarSectionUpdater.update!(
|
|
sidebar_section:,
|
|
user: current_user,
|
|
section_params: permitted_section_params,
|
|
links_params: permitted_links_params,
|
|
)
|
|
|
|
render_serialized(sidebar_section.reload, SidebarSectionSerializer)
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
render_json_error(e.record.errors.full_messages.first)
|
|
rescue ActiveRecord::NestedAttributes::TooManyRecords => e
|
|
render_json_error(e.message)
|
|
rescue Discourse::InvalidAccess
|
|
render json: failed_json, status: :forbidden
|
|
end
|
|
|
|
def reset
|
|
sidebar_section = SidebarSection.find_by(id: params[:id])
|
|
raise Discourse::InvalidParameters if !sidebar_section
|
|
@guardian.ensure_can_edit!(sidebar_section)
|
|
|
|
case sidebar_section.section_type
|
|
when "community"
|
|
sidebar_section.reset_community!
|
|
end
|
|
|
|
render_serialized(sidebar_section, SidebarSectionSerializer)
|
|
end
|
|
|
|
def destroy
|
|
sidebar_section = SidebarSection.find(section_params["id"])
|
|
@guardian.ensure_can_delete!(sidebar_section)
|
|
sidebar_section.destroy!
|
|
|
|
if sidebar_section.public?
|
|
StaffActionLogger.new(current_user).log_destroy_public_sidebar_section(sidebar_section)
|
|
MessageBus.publish("/refresh-sidebar-sections", nil)
|
|
end
|
|
|
|
render json: success_json
|
|
rescue Discourse::InvalidAccess
|
|
render json: failed_json, status: :forbidden
|
|
end
|
|
|
|
def section_params(sidebar_section = nil)
|
|
section_params = params.permit(:id, :title).to_h.with_indifferent_access
|
|
|
|
if current_user.admin?
|
|
section_params.merge!(params.permit(:public).to_h.with_indifferent_access)
|
|
|
|
if SiteSetting.content_localization_enabled &&
|
|
guardian.can_localize_sidebar_section_title?(sidebar_section, public: submitted_public)
|
|
validate_source_locale!(params[:locale], sidebar_section&.locale)
|
|
section_params.merge!(
|
|
params
|
|
.permit(:locale, localizations: %i[id locale title _destroy])
|
|
.to_h
|
|
.with_indifferent_access,
|
|
)
|
|
end
|
|
end
|
|
|
|
section_is_public = ActiveModel::Type::Boolean.new.cast(section_params[:public])
|
|
section_params.merge!(user: current_user) if !section_is_public
|
|
if section_params[:localizations]
|
|
section_params[:localizations_attributes] = prepare_localization_attributes(
|
|
section_params.delete(:localizations),
|
|
resolved_source_locale(section_params[:locale], sidebar_section&.locale),
|
|
)
|
|
end
|
|
section_params
|
|
end
|
|
|
|
def links_params(sidebar_section = nil)
|
|
permitted_link_params = %i[icon name value id _destroy segment]
|
|
if current_user.admin? && SiteSetting.content_localization_enabled
|
|
permitted_link_params << :locale
|
|
permitted_link_params << { localizations: %i[id locale name _destroy] }
|
|
end
|
|
|
|
links = params.permit(links: permitted_link_params)["links"]
|
|
public_submitted = submitted_public
|
|
stored_locales = nil
|
|
|
|
links&.each do |link|
|
|
next if link[:locale].blank? && link[:localizations].blank?
|
|
|
|
if sidebar_section.present? &&
|
|
!guardian.can_localize_sidebar_section_link?(
|
|
sidebar_section,
|
|
link[:value],
|
|
public: public_submitted,
|
|
)
|
|
link.delete(:locale)
|
|
link.delete(:localizations)
|
|
next
|
|
end
|
|
|
|
stored_locales ||=
|
|
sidebar_section ? sidebar_section.sidebar_urls.to_h { |url| [url.id, url.locale] } : {}
|
|
stored_locale = stored_locales[link[:id].to_i]
|
|
validate_source_locale!(link[:locale], stored_locale.presence || sidebar_section&.locale)
|
|
|
|
next if link[:localizations].blank?
|
|
|
|
link[:localizations_attributes] = prepare_localization_attributes(
|
|
link.delete(:localizations),
|
|
resolved_source_locale(link[:locale], stored_locale),
|
|
)
|
|
end
|
|
|
|
links
|
|
end
|
|
|
|
def reorder_params
|
|
params.permit(:sidebar_section_id, links_order: [])
|
|
end
|
|
|
|
private
|
|
|
|
def check_access_if_public
|
|
return true if !submitted_public
|
|
|
|
raise Discourse::InvalidAccess.new if !guardian.can_create_public_sidebar_section?
|
|
end
|
|
|
|
def validate_source_locale!(locale, stored_locale)
|
|
return if locale.blank? || locale == stored_locale
|
|
return if LocaleSiteSetting.supported_locales.include?(locale)
|
|
|
|
raise Discourse::InvalidParameters.new(:locale)
|
|
end
|
|
|
|
def resolved_source_locale(submitted_locale, stored_locale)
|
|
return stored_locale.presence || SiteSetting.default_locale if submitted_locale.nil?
|
|
|
|
submitted_locale.presence || SiteSetting.default_locale
|
|
end
|
|
|
|
def submitted_public
|
|
ActiveModel::Type::Boolean.new.cast(params[:public]) if params.key?(:public)
|
|
end
|
|
|
|
def ensure_localization_params_allowed(sidebar_section)
|
|
return if !SiteSetting.content_localization_enabled
|
|
|
|
public = submitted_public
|
|
|
|
if (params[:locale].present? || params[:localizations].present?) &&
|
|
!guardian.can_localize_sidebar_section_title?(sidebar_section, public:)
|
|
raise Discourse::InvalidAccess
|
|
end
|
|
|
|
params[:links]&.each do |link|
|
|
next if link[:localizations].blank?
|
|
next if guardian.can_localize_sidebar_section_link?(sidebar_section, link[:value], public:)
|
|
|
|
raise Discourse::InvalidAccess
|
|
end
|
|
end
|
|
|
|
def prepare_localization_attributes(localizations, source_locale)
|
|
localizations.filter_map do |localization|
|
|
next localization if ActiveModel::Type::Boolean.new.cast(localization[:_destroy])
|
|
next localization if !LocaleNormalizer.is_same?(localization[:locale], source_locale)
|
|
|
|
if exact_locale_match?(localization[:locale], source_locale) && localization[:id].present?
|
|
localization.merge(_destroy: true)
|
|
end
|
|
end
|
|
end
|
|
|
|
def exact_locale_match?(locale, source_locale)
|
|
LocaleNormalizer.normalize_to_i18n(locale) == LocaleNormalizer.normalize_to_i18n(source_locale)
|
|
end
|
|
end
|