From 37c542bee952193ebfbced8529b8699e2712fdc8 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 30 Jul 2026 16:50:19 +0200 Subject: Localise model validation messages and refusal reasons --- app/models/node.rb | 42 +++++++++++++++++++------------ app/models/related_asset.rb | 2 +- config/locales/de.yml | 37 ++++++++++++++++++++++++++- config/locales/en.yml | 30 ++++++++++++++++++++++ test/controllers/nodes_controller_test.rb | 2 +- test/models/node_test.rb | 17 +++++++++++++ test/models/related_asset_test.rb | 2 +- 7 files changed, 112 insertions(+), 20 deletions(-) diff --git a/app/models/node.rb b/app/models/node.rb index 1e61f9fe..602382fb 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -236,7 +236,8 @@ class Node < ApplicationRecord return nil unless self.draft || staged_slug || staged_parent_id if in_trash? || trash_node? - raise ActiveRecord::RecordInvalid.new(self), "Cannot publish a node in the Trash" + errors.add(:base, :publish_in_trash) + raise ActiveRecord::RecordInvalid.new(self) end path_before = self.unique_name @@ -265,7 +266,8 @@ class Node < ApplicationRecord new_parent = Node.find(staged_parent_id) if new_parent == self || self.descendants.include?(new_parent) - raise ActiveRecord::RecordInvalid.new(self), "Cannot move a node under itself or one of its own descendants" + errors.add(:base, :move_under_self) + raise ActiveRecord::RecordInvalid.new(self) end self.staged_parent_id = nil @@ -316,7 +318,10 @@ class Node < ApplicationRecord # at the root, carrying the leaving-public-view snapshot. def trash! current_user = nil return nil if in_trash? - raise ActiveRecord::RecordInvalid.new(self), "The Trash node itself cannot be trashed" if trash_node? + if trash_node? + errors.add(:base, :trash_the_trash) + raise ActiveRecord::RecordInvalid.new(self) + end ActiveRecord::Base.transaction do path_before = unique_name @@ -360,7 +365,8 @@ class Node < ApplicationRecord if new_parent.nil? || new_parent == self || descendants.include?(new_parent) || new_parent.trash_node? || new_parent.in_trash? - raise ActiveRecord::RecordInvalid.new(self), "Restore target must be a living node" + errors.add(:base, :restore_target_invalid) + raise ActiveRecord::RecordInvalid.new(self) end ActiveRecord::Base.transaction do @@ -376,7 +382,7 @@ class Node < ApplicationRecord end end - # Final deletion -- only from inside the Trash. Removes the whole + # Final deletion, only from inside the Trash. Removes the whole # subtree, deepest first, each node through a real destroy! so every # per-node cascade runs (the categorical difference from the old # delete_all nuke). refuse_destroy_with_children on bare destroy is @@ -384,7 +390,10 @@ class Node < ApplicationRecord # One log entry at the root, per the subtree rule, written before the # rows die. def destroy_from_trash! current_user = nil - raise ActiveRecord::RecordInvalid.new(self), "Nodes are only destroyed from the Trash" unless in_trash? + unless in_trash? + errors.add(:base, :destroy_outside_trash) + raise ActiveRecord::RecordInvalid.new(self) + end ActiveRecord::Base.transaction do doomed = self_and_descendants_ordered_with_level @@ -476,7 +485,8 @@ class Node < ApplicationRecord # :headline => nil | :set | :kept_existing | :not_eligible } def attach_asset! asset, user:, headline: false if in_trash? || trash_node? - raise ActiveRecord::RecordInvalid.new(self), "Cannot attach assets to a node in the Trash" + errors.add(:base, :attach_in_trash) + raise ActiveRecord::RecordInvalid.new(self) end if lock_owner && lock_owner != user @@ -610,7 +620,7 @@ class Node < ApplicationRecord # The Trash feature will be the ordinary path to deletion. def refuse_destroy_with_children return unless children.exists? - errors.add(:base, "Cannot destroy a node that still has children") + errors.add(:base, :has_children) throw :abort end @@ -668,15 +678,15 @@ class Node < ApplicationRecord def reserved_slug_stays_reserved if parent&.root? && !trash_node_already_me? - errors.add(:slug, "is reserved for the Trash") if slug == CccConventions::TRASH_SLUG - errors.add(:staged_slug, "is reserved for the Trash") if staged_slug == CccConventions::TRASH_SLUG + errors.add(:slug, :reserved_for_trash) if slug == CccConventions::TRASH_SLUG + errors.add(:staged_slug, :reserved_for_trash) if staged_slug == CccConventions::TRASH_SLUG end if persisted? && slug_was == CccConventions::TRASH_SLUG && Node.find(id).trash_node? - errors.add(:slug, "of the Trash node cannot change") if slug_changed? - errors.add(:parent_id, "of the Trash node cannot change") if parent_id_changed? - errors.add(:staged_slug, "must stay empty on the Trash node") if staged_slug.present? - errors.add(:staged_parent_id, "must stay empty on the Trash node") if staged_parent_id.present? + errors.add(:slug, :trash_immutable) if slug_changed? + errors.add(:parent_id, :trash_immutable) if parent_id_changed? + errors.add(:staged_slug, :trash_must_be_empty) if staged_slug.present? + errors.add(:staged_parent_id, :trash_must_be_empty) if staged_parent_id.present? end end @@ -687,12 +697,12 @@ class Node < ApplicationRecord def no_head_inside_trash return unless head_id.present? - errors.add(:head_id, "cannot exist inside the Trash") if in_trash? || trash_node? + errors.add(:head_id, :inside_trash) if in_trash? || trash_node? end def refuse_destroying_trash_node return unless trash_node? - errors.add(:base, "The Trash node cannot be destroyed") + errors.add(:base, :trash_undeletable) throw :abort end end diff --git a/app/models/related_asset.rb b/app/models/related_asset.rb index 8f8d49cb..caf8afda 100644 --- a/app/models/related_asset.rb +++ b/app/models/related_asset.rb @@ -12,6 +12,6 @@ class RelatedAsset < ApplicationRecord def headline_only_for_images return unless asset - errors.add(:headline, "can only be set on image or PDF assets") if headline? && !(asset.image? || asset.pdf?) + errors.add(:headline, :images_and_pdfs_only) if headline? && !(asset.image? || asset.pdf?) end end diff --git a/config/locales/de.yml b/config/locales/de.yml index e8a65cea..647caf57 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -114,6 +114,41 @@ de: node_id: "Node-ID" path: "Pfad" title: "Titel" + node: + slug: "Slug" + staged_slug: "Vorgemerkter Slug" + parent_id: "Eltern-Node" + staged_parent_id: "Vorgemerkter Eltern-Node" + head_id: "Head" + related_asset: + headline: "Aufmacher" + + errors: + models: + node: + # Attribute messages: full_messages prefixes the human attribute + # name, so these read as fragments. Defined at model level rather + # than per attribute, because several attributes share a message. + reserved_for_trash: "ist für den Papierkorb reserviert" + trash_immutable: "des Papierkorb-Nodes kann nicht geändert werden" + trash_must_be_empty: "muss auf dem Papierkorb-Node leer bleiben" + inside_trash: "kann im Papierkorb nicht existieren" + attributes: + base: + # :base messages stand alone -- no attribute name is prefixed, + # so these are whole sentences. + has_children: "Ein Node mit Kindern kann nicht gelöscht werden" + trash_undeletable: "Der Papierkorb-Node kann nicht gelöscht werden" + publish_in_trash: "Ein Node im Papierkorb kann nicht veröffentlicht werden" + move_under_self: "Ein Node kann nicht unter sich selbst oder einen seiner Nachfahren verschoben werden" + trash_the_trash: "Der Papierkorb-Node selbst kann nicht in den Papierkorb verschoben werden" + restore_target_invalid: "Das Wiederherstellungsziel muss ein lebender Node sein" + destroy_outside_trash: "Nodes können nur aus dem Papierkorb gelöscht werden" + attach_in_trash: "An einen Node im Papierkorb können keine Assets angehängt werden" + related_asset: + attributes: + headline: + images_and_pdfs_only: "kann nur auf Bildern oder PDFs gesetzt werden" tags: index: @@ -639,7 +674,7 @@ de: edit: title: "Menüeintrag bearbeiten" title_fields: - falls_back: "Menu-Title leer lassen, fall-back nach deutsch" + falls_back: "Menu-Titel. Leere Titel werden aus deutsch übernommen" layouts: application: diff --git a/config/locales/en.yml b/config/locales/en.yml index da87777d..1b112e3d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -66,6 +66,36 @@ en: node_id: "Node Id" path: "Path" title: "Title" + node: + slug: "Slug" + staged_slug: "Staged slug" + parent_id: "Parent node" + staged_parent_id: "Staged parent node" + head_id: "Head" + related_asset: + headline: "Headline" + + errors: + models: + node: + reserved_for_trash: "is reserved for the Trash" + trash_immutable: "of the Trash node cannot change" + trash_must_be_empty: "must stay empty on the Trash node" + inside_trash: "cannot exist inside the Trash" + attributes: + base: + has_children: "Cannot destroy a node that still has children" + trash_undeletable: "The Trash node cannot be destroyed" + publish_in_trash: "Cannot publish a node in the Trash" + move_under_self: "Cannot move a node under itself or one of its own descendants" + trash_the_trash: "The Trash node itself cannot be trashed" + restore_target_invalid: "Restore target must be a living node" + destroy_outside_trash: "Nodes are only destroyed from the Trash" + attach_in_trash: "Cannot attach assets to a node in the Trash" + related_asset: + attributes: + headline: + images_and_pdfs_only: "can only be set on image or PDF assets" tags: index: diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index d7a3abbd..0b6e65ee 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -628,7 +628,7 @@ class NodesControllerTest < ActionController::TestCase test "chapters renders the curated heading" do login_as :quentin get :chapters - assert_select "h1", "Chapters" + assert_select "h1", "Dezentrale" end test "sitemap collapses configured paths but leaves others open" do diff --git a/test/models/node_test.rb b/test/models/node_test.rb index c5b0d6a2..c1316ea6 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -919,4 +919,21 @@ class NodeTest < ActiveSupport::TestCase node.reload assert_raises(ActiveRecord::RecordInvalid) { node.publish_draft! } end + + test "refusals localise their message rather than hardcoding it" do + trash = Node.trash + + I18n.with_locale(:de) do + error = assert_raises(ActiveRecord::RecordInvalid) { trash.trash! } + assert_includes error.message, + I18n.t("activerecord.errors.models.node.attributes.base.trash_the_trash") + end + + I18n.with_locale(:en) do + trash.errors.clear + error = assert_raises(ActiveRecord::RecordInvalid) { trash.trash! } + assert_includes error.message, + I18n.t("activerecord.errors.models.node.attributes.base.trash_the_trash") + end + end end diff --git a/test/models/related_asset_test.rb b/test/models/related_asset_test.rb index bb86ddbb..ea7c3d35 100644 --- a/test/models/related_asset_test.rb +++ b/test/models/related_asset_test.rb @@ -29,7 +29,7 @@ class RelatedAssetTest < ActiveSupport::TestCase related.headline = true assert_not related.valid? - assert_includes related.errors[:headline], "can only be set on image or PDF assets" + assert_includes related.errors[:headline], I18n.t("activerecord.errors.models.related_asset.attributes.headline.images_and_pdfs_only") end test "the headline validation does not raise when asset is missing" do -- cgit v1.3