mirror of
https://github.com/discourse/discourse.git
synced 2026-08-07 13:19:19 +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.
120 lines
3.6 KiB
Ruby
Vendored
120 lines
3.6 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
class SidebarUrl < ActiveRecord::Base
|
|
include Localizable
|
|
|
|
enum :segment, { primary: 0, secondary: 1 }, scopes: false, suffix: true
|
|
|
|
MAX_ICON_LENGTH = 40
|
|
MAX_NAME_LENGTH = 80
|
|
MAX_VALUE_LENGTH = 1000
|
|
COMMUNITY_SECTION_LINKS = [
|
|
{
|
|
name: "Topics",
|
|
path: "/latest",
|
|
icon: "layer-group",
|
|
segment: SidebarUrl.segments["primary"],
|
|
},
|
|
{
|
|
name: "My posts",
|
|
path: "/my/activity",
|
|
icon: "user",
|
|
segment: SidebarUrl.segments["primary"],
|
|
},
|
|
{
|
|
name: "My messages",
|
|
path: "/my/messages",
|
|
icon: "inbox",
|
|
segment: SidebarUrl.segments["primary"],
|
|
},
|
|
{ name: "Review", path: "/review", icon: "flag", segment: SidebarUrl.segments["primary"] },
|
|
{ name: "Admin", path: "/admin", icon: "wrench", segment: SidebarUrl.segments["primary"] },
|
|
{
|
|
name: "Invite",
|
|
path: "/new-invite",
|
|
icon: "paper-plane",
|
|
segment: SidebarUrl.segments["primary"],
|
|
},
|
|
{ name: "Users", path: "/u", icon: "users", segment: SidebarUrl.segments["secondary"] },
|
|
{
|
|
name: "About",
|
|
path: "/about",
|
|
icon: "circle-info",
|
|
segment: SidebarUrl.segments["secondary"],
|
|
},
|
|
{
|
|
name: "FAQ",
|
|
path: "/faq",
|
|
icon: "circle-question",
|
|
segment: SidebarUrl.segments["secondary"],
|
|
},
|
|
{ name: "Groups", path: "/g", icon: "user-group", segment: SidebarUrl.segments["secondary"] },
|
|
{
|
|
name: "Badges",
|
|
path: "/badges",
|
|
icon: "certificate",
|
|
segment: SidebarUrl.segments["secondary"],
|
|
},
|
|
{ name: "Filter", path: "/filter", icon: "filter", segment: SidebarUrl.segments["secondary"] },
|
|
]
|
|
COMMUNITY_SECTION_LINK_PATHS = COMMUNITY_SECTION_LINKS.map { |link| link[:path] }.freeze
|
|
|
|
validates :icon, presence: true, length: { maximum: MAX_ICON_LENGTH }
|
|
validates :name, presence: true, length: { maximum: MAX_NAME_LENGTH }
|
|
validates :value, presence: true, length: { maximum: MAX_VALUE_LENGTH }
|
|
validates :locale, presence: true, length: { maximum: 20 }
|
|
|
|
validate :path_validator
|
|
|
|
accepts_nested_attributes_for :localizations, allow_destroy: true
|
|
|
|
before_validation :remove_internal_hostname, :set_external
|
|
before_validation :set_default_locale
|
|
|
|
def path_validator
|
|
return true if !external?
|
|
raise ActionController::RoutingError.new("Not Found") if value !~ Discourse::Utils::URI_REGEXP
|
|
rescue ActionController::RoutingError
|
|
errors.add(
|
|
:value,
|
|
I18n.t("activerecord.errors.models.sidebar_section_link.attributes.linkable_type.invalid"),
|
|
)
|
|
end
|
|
|
|
def remove_internal_hostname
|
|
self.value = value.sub(%r{\Ahttp(s)?://#{Discourse.current_hostname}}, "")
|
|
end
|
|
|
|
def set_external
|
|
self.external = value.start_with?("http://", "https://")
|
|
end
|
|
|
|
def set_default_locale
|
|
self.locale = SiteSetting.default_locale.to_s if locale.blank?
|
|
end
|
|
|
|
def self.built_in_community_section_link_value?(value)
|
|
normalized_value =
|
|
value.to_s.sub(%r{\Ahttps?://#{Regexp.escape(Discourse.current_hostname)}}, "")
|
|
COMMUNITY_SECTION_LINK_PATHS.include?(normalized_value)
|
|
end
|
|
|
|
def built_in_community_section_link?
|
|
self.class.built_in_community_section_link_value?(value)
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: sidebar_urls
|
|
#
|
|
# id :bigint not null, primary key
|
|
# external :boolean default(FALSE), not null
|
|
# icon :string(40) not null
|
|
# locale :string(20)
|
|
# name :string(80) not null
|
|
# segment :integer default("primary"), not null
|
|
# value :string(1000) not null
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
#
|