summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-13 16:36:38 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-13 16:36:38 +0200
commit0b6ae26f3b4c3c8278e569fa6644a544e996c25d (patch)
tree6c0e8dd23fa243e6bf7e6473aebae3f13ea20dea
parente2705119ba718d40ff4f29c00027c2dfecef01ce (diff)
Add per-node translation management: list, edit, destroy, compare
Replaces the old locale-switch-and-edit-the-same-screen workflow, which conflated presentation locale with content locale and let an editor silently drift into editing the wrong language with no persistent signal that anything had changed. Non-default-locale content now has its own explicit routes and screens, never sharing a route param with the ambient chrome locale. - PageTranslationsController: index/show/edit/update/destroy, scoped to Page.non_default_locales; update handles first-time creation too, so there's no separate new/create step. - Reads go through the actual PageTranslation row (Page#translation_summary), never through the Globalize fallback-bearing accessor -- fallback is correct for public rendering but wrong for editing, where a missing translation needs to look empty, not borrowed from another locale. - Translations ride on the page's own draft/head cycle; no independent publish state. - nodes#show gains a Translations section (per-locale Lock+Edit / Create+Lock, Destroy, a link into the read-only Compare view) and a locale indicator on its own default-locale content; nodes#edit, nodes#update, and nodes#autosave are pinned to the default locale via Globalize.with_locale regardless of the ambient route locale. - nodes#show no longer double-loads the node or calls wipe_draft! on every view (see previous commit for why that's now safe). - .preview_link_row is renamed .aligned_action_row now that it has a second real consumer.
-rw-r--r--app/controllers/nodes_controller.rb12
-rw-r--r--app/controllers/page_translations_controller.rb98
-rw-r--r--app/models/page.rb21
-rw-r--r--app/views/nodes/show.html.erb49
-rw-r--r--app/views/page_translations/edit.html.erb39
-rw-r--r--app/views/page_translations/index.html.erb19
-rw-r--r--app/views/page_translations/show.html.erb27
-rw-r--r--config/routes.rb6
-rw-r--r--public/stylesheets/admin.css27
-rw-r--r--test/controllers/page_translations_controller_test.rb78
10 files changed, 366 insertions, 10 deletions
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index c1468be..249602d 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -16,6 +16,8 @@ class NodesController < ApplicationController
16 :revert 16 :revert
17 ] 17 ]
18 18
19 around_action :pin_to_default_locale, :only => [:show, :edit, :update, :autosave]
20
19 def index 21 def index
20 @nodes = Node.root.descendants.includes(:head, :draft) 22 @nodes = Node.root.descendants.includes(:head, :draft)
21 .order('id DESC') 23 .order('id DESC')
@@ -59,9 +61,9 @@ class NodesController < ApplicationController
59 end 61 end
60 62
61 def show 63 def show
62 node = Node.find(params[:id]) 64 @page = @node.draft || @node.head
63 node.wipe_draft! 65 @default_translation = @page.translations.find_by(:locale => I18n.default_locale)
64 @page = node.draft || node.head 66 @translations = @page.translation_summary
65 end 67 end
66 68
67 def edit 69 def edit
@@ -248,6 +250,10 @@ class NodesController < ApplicationController
248 end 250 end
249 end 251 end
250 252
253 def pin_to_default_locale
254 Globalize.with_locale(I18n.default_locale) { yield }
255 end
256
251 def descendant_counts_for(ordered_with_level) 257 def descendant_counts_for(ordered_with_level)
252 counts = Hash.new(0) 258 counts = Hash.new(0)
253 stack = [] # [node, level, index] 259 stack = [] # [node, level, index]
diff --git a/app/controllers/page_translations_controller.rb b/app/controllers/page_translations_controller.rb
new file mode 100644
index 0000000..47f01f7
--- /dev/null
+++ b/app/controllers/page_translations_controller.rb
@@ -0,0 +1,98 @@
1class PageTranslationsController < ApplicationController
2 layout 'admin'
3
4 before_action :login_required
5 before_action :find_node
6 before_action :find_locale, :only => [:show, :edit, :update, :destroy]
7
8 def index
9 page = @node.draft || @node.head
10 @translations = page ? page.translation_summary : []
11 end
12
13 def show
14 @page = @node.draft || @node.head
15 @translation = @page.translations.find_by(:locale => @locale)
16 @default_translation = @page.translations.find_by(:locale => I18n.default_locale)
17 end
18
19 def edit
20 @node.lock_for_editing!(current_user)
21 @page = @node.draft || @node.head
22 @translation = @page.translations.find_by(:locale => @locale)
23 rescue LockedByAnotherUser => e
24 flash[:error] = e.message
25 redirect_to node_path(@node)
26 end
27
28 def update
29 draft = ensure_editable_draft
30 Globalize.with_locale(@locale) { draft.update!(translation_params) }
31 flash[:notice] = "#{@locale.upcase} translation saved. Publish the draft to make it live."
32
33 if params[:commit] == "Save + Unlock + Exit"
34 @node.unlock!
35 redirect_to node_path(@node)
36 else
37 redirect_to edit_node_translation_path(@node, @locale)
38 end
39 rescue LockedByAnotherUser => e
40 flash[:error] = e.message
41 redirect_to node_path(@node)
42 end
43
44 def destroy
45 base = @node.draft || @node.head
46 unless base && base.translated_locales.include?(@locale)
47 flash[:error] = "No #{@locale.to_s.upcase} translation exists to remove."
48 return redirect_to node_path(@node)
49 end
50
51 if (base.translated_locales - [@locale]).empty?
52 flash[:error] = "Can't remove the only remaining translation."
53 return redirect_to node_path(@node)
54 end
55
56 draft = ensure_editable_draft
57 draft.translations.where(:locale => @locale).delete_all
58 draft.reload
59
60 flash[:notice] = "#{@locale.upcase} translation removed from the draft. Publish to make this permanent."
61 redirect_to node_path(@node)
62 rescue LockedByAnotherUser => e
63 flash[:error] = e.message
64 redirect_to node_path(@node)
65 end
66
67 private
68
69 def find_node
70 @node = Node.find(params[:node_id])
71 end
72
73 # Every remaining action's :translation_locale is already
74 # router-constrained to non-default locales, so this should never
75 # actually fire in practice -- it's a deliberate second check, kept
76 # as a drift detector in case the route constraint and this list
77 # (both driven by Page.non_default_locales) ever disagree.
78 def find_locale
79 candidate = params[:translation_locale].to_s.to_sym
80 unless Page.non_default_locales.include?(candidate)
81 raise ActionController::RoutingError, "Unknown or default locale"
82 end
83 @locale = candidate
84 end
85
86 # Never mutates head directly. Locks first (same guard as the
87 # primary editor), then reuses an existing draft or creates one --
88 # deliberately not going through the autosave buffer: this is a
89 # plain, explicit Save, not a live-typing surface.
90 def ensure_editable_draft
91 @node.lock_for_editing!(current_user)
92 @node.draft || @node.create_new_draft(current_user)
93 end
94
95 def translation_params
96 params.fetch(:page, {}).permit(:title, :abstract, :body)
97 end
98end
diff --git a/app/models/page.rb b/app/models/page.rb
index db5b688..f796228 100644
--- a/app/models/page.rb
+++ b/app/models/page.rb
@@ -87,6 +87,27 @@ class Page < ApplicationRecord
87 end 87 end
88 end 88 end
89 89
90 def self.non_default_locales
91 I18n.available_locales - [:root, I18n.default_locale]
92 end
93
94 # One row per non-default locale, read from the actual translation
95 # row -- never through the locale-dependent accessor, so a locale
96 # with no real translation yet reports as absent rather than quietly
97 # showing a fallback value borrowed from another locale.
98 def translation_summary
99 Page.non_default_locales.map do |locale|
100 translation = translations.find_by(:locale => locale)
101 {
102 :locale => locale,
103 :exists => translation.present?,
104 :title => translation&.title,
105 :updated_at => translation&.updated_at,
106 :outdated => translation.present? && outdated_translations?(:locale => locale)
107 }
108 end
109 end
110
90 def self.untranslated(options = {:locale => :de}) 111 def self.untranslated(options = {:locale => :de})
91 PageTranslation.all.group_by(&:page_id).select do |k,v| 112 PageTranslation.all.group_by(&:page_id).select do |k,v|
92 v.size == 1 && v.map{|x| x.locale}.include?(options[:locale]) 113 v.size == 1 && v.map{|x| x.locale}.include?(options[:locale])
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb
index 54bb38f..e6a922b 100644
--- a/app/views/nodes/show.html.erb
+++ b/app/views/nodes/show.html.erb
@@ -1,6 +1,6 @@
1<% locked_by_other = @node.locked? && @node.lock_owner != current_user %> 1<% locked_by_other = @node.locked? && @node.lock_owner != current_user %>
2<div id="page_editor" class="show_node"> 2<div id="page_editor" class="show_node">
3 <h1><%= title_for_node(@node) %></h1> 3 <h1><%= title_for_node(@node) %> <small>(<%= I18n.default_locale.to_s.upcase %>)</small></h1>
4 <div id="content"> 4 <div id="content">
5 <div class="node_description">Status</div> 5 <div class="node_description">Status</div>
6 <div class="node_content node_info_group node_status"> 6 <div class="node_content node_info_group node_status">
@@ -70,6 +70,46 @@
70 <% end %> 70 <% end %>
71 </div> 71 </div>
72 72
73 <div class="node_description">Translations</div>
74 <div class="node_content node_info_group">
75 <div class="node_info_group_items">
76 <div class="node_info_item">
77 <span class="node_info_label"><%= I18n.default_locale.to_s.upcase %> (default)</span>
78 <%= @page.title %> — updated <%= @default_translation&.updated_at || @page.updated_at %>
79 </div>
80 <% @translations.each do |t| %>
81 <div class="node_info_item">
82 <span class="node_info_label"><%= t[:locale].to_s.upcase %></span>
83 <div class="aligned_action_row">
84 <% if t[:exists] %>
85 <%= link_to t[:title], node_translation_path(@node, t[:locale]) %> — updated <%= t[:updated_at] %>
86 <% if t[:outdated] %>
87 <span class="warning">may be outdated</span>
88 <% end %>
89 <% else %>
90 <span class="field_hint">Not yet translated.</span>
91 <% end %>
92 <% if locked_by_other %>
93 <span class="disabled_action"><%= t[:exists] ? "Lock + Edit" : "Create translation + Lock" %></span>
94 <% else %>
95 <%= link_to edit_node_translation_path(@node, t[:locale]), class: "action_button" do %>
96 <%= icon(t[:exists] ? "edit" : "language", library: "tabler", "aria-hidden": true) %>
97 <%= t[:exists] ? "Lock + Edit" : "Create translation + Lock" %>
98 <% end %>
99 <% if t[:exists] %>
100 <%= button_to node_translation_path(@node, t[:locale]), method: :delete,
101 form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } do %>
102 <%= icon("trash", library: "tabler", "aria-hidden": true) %>
103 Destroy Translation
104 <% end %>
105 <% end %>
106 <% end %>
107 </div>
108 </div>
109 <% end %>
110 </div>
111 </div>
112
73 <div class="node_description">People</div> 113 <div class="node_description">People</div>
74 <div class="node_content node_info_group"> 114 <div class="node_content node_info_group">
75 <div class="node_info_group_items"> 115 <div class="node_info_group_items">
@@ -122,7 +162,7 @@
122 <div class="node_info_item"> 162 <div class="node_info_item">
123 <span class="node_info_label">Public Preview</span> 163 <span class="node_info_label">Public Preview</span>
124 <% if @node.draft.preview_token.present? %> 164 <% if @node.draft.preview_token.present? %>
125 <div class="preview_link_row"> 165 <div class="aligned_action_row">
126 <%= link_to shared_preview_path(token: @node.draft.preview_token), shared_preview_url(token: @node.draft.preview_token), target: "_blank", rel: "noopener" %> 166 <%= link_to shared_preview_path(token: @node.draft.preview_token), shared_preview_url(token: @node.draft.preview_token), target: "_blank", rel: "noopener" %>
127 <%= button_to 'Revoke', revoke_shared_preview_node_path(@node), method: :put, form: { data: { confirm: "Revoke this preview link? Anyone currently using it will immediately lose access." }, class: 'button_to state_changing' } %> 167 <%= button_to 'Revoke', revoke_shared_preview_node_path(@node), method: :put, form: { data: { confirm: "Revoke this preview link? Anyone currently using it will immediately lose access." }, class: 'button_to state_changing' } %>
128 </div> 168 </div>
@@ -205,12 +245,11 @@
205 </div> 245 </div>
206 <% end %> 246 <% end %>
207 247
208 <div class="node_description">Abstract</div> 248 <div class="node_description">Abstract (<%= I18n.default_locale.to_s.upcase %>)</div>
209 <div class="node_content"><%= sanitize(@page.abstract) %></div> 249 <div class="node_content"><%= sanitize(@page.abstract) %></div>
210 250
211 <div class="node_description">Body</div> 251 <div class="node_description">Body (<%= I18n.default_locale.to_s.upcase %>)</div>
212 <div class="node_content"><%= sanitize(@page.body) %></div> 252 <div class="node_content"><%= sanitize(@page.body) %></div>
213 <%# Assets not yet addressed - no confirmed model/association seen for this page's attached assets %>
214 253
215 </div> 254 </div>
216</div> 255</div>
diff --git a/app/views/page_translations/edit.html.erb b/app/views/page_translations/edit.html.erb
new file mode 100644
index 0000000..7371a42
--- /dev/null
+++ b/app/views/page_translations/edit.html.erb
@@ -0,0 +1,39 @@
1<div class="translation_banner">
2 Editing the <strong><%= @locale.to_s.upcase %></strong> translation of <strong><%= title_for_node(@node) %></strong>.
3</div>
4
5<p class="field_hint">
6 Metadata such as images, tags, template, and author belong to the page
7 as a whole, not to a single translation — change those from the
8 <%= link_to 'default-locale editor', edit_node_path(@node) %>.
9</p>
10
11<div class="node_action_bar">
12 <%= button_to 'Unlock + Back', unlock_node_path(@node), method: :put,
13 form: { class: 'button_to state_changing' },
14 disabled: @node.autosave.present? %>
15 <%= submit_tag "Save #{@locale.to_s.upcase} translation", form: "translation_edit_form" %>
16 <%= submit_tag "Save + Unlock + Exit", form: "translation_edit_form" %>
17 <%= link_to "Preview ↗", preview_page_path(@page, :locale => @locale), target: "_blank", rel: "noopener", class: "preview_link" %>
18</div>
19
20<div id="page_editor">
21 <%= form_with url: node_translation_path(@node, @locale), method: :patch, local: true, id: "translation_edit_form" do |f| %>
22 <div id="content">
23 <div class="node_description">Title</div>
24 <div class="node_content">
25 <%= text_field_tag "page[title]", @translation&.title %>
26 </div>
27
28 <div class="node_description">Abstract</div>
29 <div class="node_content">
30 <%= text_area_tag "page[abstract]", @translation&.abstract %>
31 </div>
32
33 <div class="node_description">Body</div>
34 <div class="node_content">
35 <%= text_area_tag "page[body]", @translation&.body, :class => 'with_editor' %>
36 </div>
37 </div>
38 <% end %>
39</div>
diff --git a/app/views/page_translations/index.html.erb b/app/views/page_translations/index.html.erb
new file mode 100644
index 0000000..a8c4f5d
--- /dev/null
+++ b/app/views/page_translations/index.html.erb
@@ -0,0 +1,19 @@
1<h1>Translations — <%= title_for_node(@node) %></h1>
2
3<div class="node_content node_info_group">
4 <div class="node_info_group_items">
5 <% @translations.each do |t| %>
6 <div class="node_info_item">
7 <span class="node_info_label"><%= t[:locale].to_s.upcase %></span>
8 <% if t[:exists] %>
9 <%= t[:title] %> — updated <%= t[:updated_at] %>
10 <% if t[:outdated] %><span class="warning">may be outdated</span><% end %>
11 <%= link_to 'Edit', edit_node_translation_path(@node, t[:locale]) %>
12 <% else %>
13 <span class="field_hint">Not yet translated.</span>
14 <%= link_to 'Add', edit_node_translation_path(@node, t[:locale]) %>
15 <% end %>
16 </div>
17 <% end %>
18 </div>
19</div>
diff --git a/app/views/page_translations/show.html.erb b/app/views/page_translations/show.html.erb
new file mode 100644
index 0000000..c5bd151
--- /dev/null
+++ b/app/views/page_translations/show.html.erb
@@ -0,0 +1,27 @@
1<h1>Compare — <%= title_for_node(@node) %></h1>
2
3<div class="translation_compare">
4 <div class="translation_compare_column">
5 <h2><%= @locale.to_s.upcase %></h2>
6 <div class="node_description">Title</div>
7 <div class="node_content"><%= @translation&.title %></div>
8
9 <div class="node_description">Abstract</div>
10 <div class="node_content"><%= sanitize(@translation&.abstract.to_s) %></div>
11
12 <div class="node_description">Body</div>
13 <div class="node_content"><%= sanitize(@translation&.body.to_s) %></div>
14 </div>
15
16 <div class="translation_compare_column">
17 <h2><%= I18n.default_locale.to_s.upcase %> (default)</h2>
18 <div class="node_description">Title</div>
19 <div class="node_content"><%= @default_translation&.title %></div>
20
21 <div class="node_description">Abstract</div>
22 <div class="node_content"><%= sanitize(@default_translation&.abstract.to_s) %></div>
23
24 <div class="node_description">Body</div>
25 <div class="node_content"><%= sanitize(@default_translation&.body.to_s) %></div>
26 </div>
27</div>
diff --git a/config/routes.rb b/config/routes.rb
index 5d61bae..38a497d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -55,6 +55,12 @@ Cccms::Application.routes.draw do
55 put :revert 55 put :revert
56 end 56 end
57 57
58 resources :translations, controller: 'page_translations',
59 param: :translation_locale,
60 constraints: { translation_locale: /en/ },
61 only: [:index, :show, :edit, :update, :destroy]
62
63
58 resources :related_assets, only: [:create, :destroy, :update] do 64 resources :related_assets, only: [:create, :destroy, :update] do
59 collection do 65 collection do
60 get :search 66 get :search
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index c3c0cb4..f1f4c05 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -637,6 +637,28 @@ table.revisions_table tr:hover {
637 margin: 0; 637 margin: 0;
638} 638}
639 639
640/* ============================================================
641 Translation compare view (page_translations#show)
642 ============================================================ */
643
644.translation_compare {
645 display: flex;
646 flex-direction: column;
647 gap: 1.5rem;
648}
649
650@media(min-width:1016px) {
651 .translation_compare {
652 flex-direction: row;
653 gap: 2rem;
654 }
655
656 .translation_compare_column {
657 flex: 1;
658 min-width: 0;
659 }
660}
661
640table.user_table td.user_login { 662table.user_table td.user_login {
641 padding-right: 30px; 663 padding-right: 30px;
642} 664}
@@ -834,14 +856,15 @@ form.button_to button[type="submit"] {
834 font-weight: bold; 856 font-weight: bold;
835} 857}
836 858
837.preview_link_row { 859.aligned_action_row {
838 display: flex; 860 display: flex;
839 flex-wrap: wrap; 861 flex-wrap: wrap;
840 align-items: start; 862 align-items: start;
841 gap: 0.5rem; 863 gap: 0.5rem;
842} 864}
843 865
844.preview_link_row form.button_to { 866.aligned_action_row .action_button,
867.aligned_action_row form.button_to {
845 margin-top: -5px; 868 margin-top: -5px;
846} 869}
847 870
diff --git a/test/controllers/page_translations_controller_test.rb b/test/controllers/page_translations_controller_test.rb
new file mode 100644
index 0000000..d84ac45
--- /dev/null
+++ b/test/controllers/page_translations_controller_test.rb
@@ -0,0 +1,78 @@
1require 'test_helper'
2
3class PageTranslationsControllerTest < ActionController::TestCase
4 test "index lists the default locale's existing translation and flags a missing one" do
5 login_as :quentin
6 node = Node.root.children.create!(:slug => "translations_index_test")
7 node.publish_draft!
8
9 get :index, params: { :node_id => node.id }
10
11 assert_response :success
12 assert_equal [:en], assigns(:translations).map { |t| t[:locale] }
13 assert_equal false, assigns(:translations).first[:exists]
14 end
15
16 test "update creates a first-time translation on a fresh draft, leaving head untouched" do
17 login_as :quentin
18 node = Node.root.children.create!(:slug => "translations_create_test")
19 Globalize.with_locale(:de) { node.draft.update!(:title => "Deutscher Titel") }
20 node.publish_draft!
21
22 patch :update, params: { :node_id => node.id, :translation_locale => "en", :page => { :title => "English Title" } }
23
24 node.reload
25 assert_not_nil node.draft
26 assert_includes node.draft.translated_locales, :en
27 assert_not_includes node.head.translated_locales, :en
28 end
29
30 test "no route exists for the default locale under the translations resource" do
31 login_as :quentin
32 node = Node.root.children.create!(:slug => "translations_route_constraint_test")
33
34 assert_raises(ActionController::UrlGenerationError) do
35 patch :update, params: { :node_id => node.id, :translation_locale => "de", :page => { :title => "x" } }
36 end
37 end
38
39 test "update with Save + Unlock + Exit unlocks the node and redirects to nodes#show" do
40 login_as :quentin
41 node = Node.root.children.create!(:slug => "translations_exit_test")
42
43 patch :update, params: { :node_id => node.id, :translation_locale => "en", :page => { :title => "x" }, :commit => "Save + Unlock + Exit" }
44
45 assert_nil node.reload.lock_owner
46 assert_redirected_to node_path(node)
47 end
48
49 test "update saves the translation onto the draft" do
50 login_as :quentin
51 node = Node.root.children.create!(:slug => "translations_update_test")
52 Globalize.with_locale(:en) { node.draft.update!(:title => "Original") }
53
54 patch :update, params: { :node_id => node.id, :translation_locale => "en", :page => { :title => "Revised" } }
55
56 assert_equal "Revised", Globalize.with_locale(:en) { node.draft.reload.title }
57 end
58
59 test "destroy refuses to remove the only remaining translation" do
60 login_as :quentin
61 node = Node.root.children.create!(:slug => "translations_destroy_last_test")
62 Globalize.with_locale(:en) { node.draft.update!(:title => "Only translation") }
63 node.draft.translations.where(:locale => :de).delete_all
64
65 delete :destroy, params: { :node_id => node.id, :translation_locale => "en" }
66
67 assert_equal "Can't remove the only remaining translation.", flash[:error]
68 end
69
70 test "destroy is a safe no-op, not a false success, when the translation doesn't exist" do
71 login_as :quentin
72 node = Node.root.children.create!(:slug => "translations_destroy_missing_test")
73
74 delete :destroy, params: { :node_id => node.id, :translation_locale => "en" }
75
76 assert_match(/No EN translation exists/, flash[:error])
77 end
78end