summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-08-07 06:15:14 +0200
committererdgeist <erdgeist@erdgeist.org>2026-08-07 06:15:14 +0200
commitf4ddfff03ca9f25d50f39a1971877362d85eb9cb (patch)
tree31e6774fd4cf2b579a9622277be150417a2fa48e
parent02a4ea750428aa1a9c9e7f2680553c0ce4ef1fec (diff)
Move the pending address from the node onto the draft
-rw-r--r--app/controllers/nodes_controller.rb19
-rw-r--r--app/models/node.rb51
-rw-r--r--app/models/page.rb28
-rw-r--r--app/views/nodes/edit.html.erb12
-rw-r--r--app/views/nodes/show.html.erb2
-rw-r--r--config/locales/de.yml8
-rw-r--r--config/locales/en.yml8
-rw-r--r--db/migrate/20260807010722_add_address_to_pages.rb6
-rw-r--r--lib/tasks/pages.rake33
-rw-r--r--public/javascripts/admin_search.js2
-rw-r--r--public/stylesheets/admin.css4
-rw-r--r--test/controllers/nodes_controller_test.rb51
-rw-r--r--test/models/node_test.rb96
-rw-r--r--test/models/node_trash_test.rb10
14 files changed, 233 insertions, 97 deletions
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index 22606674..3009e25a 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -90,7 +90,6 @@ class NodesController < ApplicationController
90 end 90 end
91 91
92 def update 92 def update
93 @node.update(node_update_params)
94 @node.autosave!( page_params.merge(:tag_list => params[:tag_list]), current_user ) 93 @node.autosave!( page_params.merge(:tag_list => params[:tag_list]), current_user )
95 @node.save_draft!(current_user) 94 @node.save_draft!(current_user)
96 95
@@ -123,7 +122,6 @@ class NodesController < ApplicationController
123 end 122 end
124 123
125 def autosave 124 def autosave
126 @node.update(node_update_params)
127 @node.autosave!( page_params.merge(:tag_list => params[:tag_list]), current_user ) 125 @node.autosave!( page_params.merge(:tag_list => params[:tag_list]), current_user )
128 head :ok 126 head :ok
129 rescue LockedByAnotherUser => e 127 rescue LockedByAnotherUser => e
@@ -167,13 +165,13 @@ class NodesController < ApplicationController
167 end 165 end
168 166
169 def restore_from_trash 167 def restore_from_trash
170 parent = Node.find(params[:parent_id]) 168 if params[:parent_id].present? && @node.draft
171 @node.restore_from_trash!(parent, current_user) 169 @node.draft.update!(:parent_node_id => params[:parent_id])
170 end
171
172 @node.restore_from_trash!(current_user)
172 flash[:notice] = t("flash.nodes.restored") 173 flash[:notice] = t("flash.nodes.restored")
173 redirect_to node_path(@node) 174 redirect_to node_path(@node)
174 rescue ActiveRecord::RecordNotFound
175 flash[:error] = t("flash.nodes.restore_target_missing")
176 redirect_to node_path(@node)
177 rescue ActiveRecord::RecordInvalid => e 175 rescue ActiveRecord::RecordInvalid => e
178 flash[:error] = e.record.errors.full_messages.to_sentence 176 flash[:error] = e.record.errors.full_messages.to_sentence
179 redirect_to node_path(@node) 177 redirect_to node_path(@node)
@@ -281,12 +279,9 @@ class NodesController < ApplicationController
281 params.fetch(:node, {}).permit(:slug, :parent_id) 279 params.fetch(:node, {}).permit(:slug, :parent_id)
282 end 280 end
283 281
284 def node_update_params
285 params.fetch(:node, {}).permit(:staged_slug, :staged_parent_id)
286 end
287
288 def page_params 282 def page_params
289 params.fetch(:page, {}).permit(:title, :abstract, :body, :template_name, :published_at, :user_id) 283 params.fetch(:page, {}).permit(:title, :abstract, :body, :template_name,
284 :published_at, :user_id, :slug, :parent_node_id)
290 end 285 end
291 286
292 def find_node 287 def find_node
diff --git a/app/models/node.rb b/app/models/node.rb
index 4b7c9772..5c28a786 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -128,6 +128,7 @@ class Node < ApplicationRecord
128 128
129 def autosave! attributes, current_user 129 def autosave! attributes, current_user
130 ensure_autosave!(current_user) 130 ensure_autosave!(current_user)
131 attributes = attributes.except(:slug, "slug") if attributes[:slug].blank? && attributes["slug"].blank?
131 self.autosave.assign_attributes(attributes) 132 self.autosave.assign_attributes(attributes)
132 self.autosave.save! 133 self.autosave.save!
133 self.autosave 134 self.autosave
@@ -227,17 +228,9 @@ class Node < ApplicationRecord
227 self.reload 228 self.reload
228 end 229 end
229 230
230 def staged_slug=(value)
231 if head.blank?
232 self.slug = value
233 else
234 super
235 end
236 end
237
238 def publish_draft! current_user = nil 231 def publish_draft! current_user = nil
239 # Return nil if nothing to publish and no staged changes 232 # Return nil if nothing to publish
240 return nil unless self.draft || staged_slug || staged_parent_id 233 return nil unless self.draft
241 234
242 guard_live_change!(current_user, :target_path => prospective_unique_name) 235 guard_live_change!(current_user, :target_path => prospective_unique_name)
243 236
@@ -263,26 +256,28 @@ class Node < ApplicationRecord
263 **NodeAction.head_diff(outgoing_head, self.head)) 256 **NodeAction.head_diff(outgoing_head, self.head))
264 end 257 end
265 258
266 if staged_slug && (staged_slug != slug) 259 if self.head.slug.present? && self.head.slug != slug
267 self.slug = staged_slug 260 self.slug = self.head.slug
268 self.staged_slug = nil
269 end 261 end
270 262
271 if staged_parent_id && (staged_parent_id != parent_id) 263 if self.head.parent_node_id && self.head.parent_node_id != parent_id
272 new_parent = Node.find(staged_parent_id) 264 new_parent = Node.find_by(:id => self.head.parent_node_id)
273 265
274 if new_parent == self || self.descendants.include?(new_parent) 266 unless new_parent
267 errors.add(:base, :move_target_missing)
268 raise ActiveRecord::RecordInvalid.new(self)
269 end
270
271 if new_parent == self || self.descendants.include?(new_parent) ||
272 new_parent.trash_node? || new_parent.in_trash?
275 errors.add(:base, :move_under_self) 273 errors.add(:base, :move_under_self)
276 raise ActiveRecord::RecordInvalid.new(self) 274 raise ActiveRecord::RecordInvalid.new(self)
277 end 275 end
278 276
279 self.staged_parent_id = nil
280 self.save! 277 self.save!
281 self.move_to_child_of(new_parent) 278 self.move_to_child_of(new_parent)
282 else 279 else
283 unless self.save 280 raise ActiveRecord::RecordInvalid.new(self) unless self.save
284 raise ActiveRecord::RecordInvalid.new(self)
285 end
286 end 281 end
287 282
288 self.reload 283 self.reload
@@ -369,9 +364,12 @@ class Node < ApplicationRecord
369 # subtree comes back exactly as it sits in the Trash: all drafts, 364 # subtree comes back exactly as it sits in the Trash: all drafts,
370 # nothing published. Republication is a separate, witnessed act 365 # nothing published. Republication is a separate, witnessed act
371 # per node. 366 # per node.
372 def restore_from_trash! new_parent, current_user = nil 367 def restore_from_trash! current_user = nil
373 return nil unless in_trash? 368 return nil unless in_trash?
374 369
370 target = (draft || head)&.parent_node_id
371 new_parent = target ? Node.find_by(:id => target) : nil
372
375 if new_parent.nil? || new_parent == self || descendants.include?(new_parent) || 373 if new_parent.nil? || new_parent == self || descendants.include?(new_parent) ||
376 new_parent.trash_node? || new_parent.in_trash? 374 new_parent.trash_node? || new_parent.in_trash?
377 errors.add(:base, :restore_target_invalid) 375 errors.add(:base, :restore_target_invalid)
@@ -593,11 +591,9 @@ class Node < ApplicationRecord
593 root? || self.class.restricted_path?(unique_name) 591 root? || self.class.restricted_path?(unique_name)
594 end 592 end
595 593
594 # Falls back to the live address when the draft records none.
596 def prospective_unique_name 595 def prospective_unique_name
597 target_parent = staged_parent_id ? Node.find_by(:id => staged_parent_id) : parent 596 (draft || head)&.prospective_unique_name || unique_name
598 return nil unless target_parent
599
600 [target_parent.unique_name.presence, staged_slug.presence || slug].compact.join("/")
601 end 597 end
602 598
603 # Returns immutable node id for all new nodes so that the atom feed entry ids 599 # Returns immutable node id for all new nodes so that the atom feed entry ids
@@ -702,7 +698,7 @@ class Node < ApplicationRecord
702 # that draft and publishes it. 698 # that draft and publishes it.
703 def initialize_empty_page 699 def initialize_empty_page
704 if self.pages.empty? 700 if self.pages.empty?
705 self.draft = self.pages.create! 701 self.draft = self.pages.create!(:slug => self.slug, :parent_node_id => self.parent_id)
706 self.save 702 self.save
707 end 703 end
708 end 704 end
@@ -744,14 +740,11 @@ class Node < ApplicationRecord
744 def reserved_slug_stays_reserved 740 def reserved_slug_stays_reserved
745 if parent&.root? && !trash_node_already_me? 741 if parent&.root? && !trash_node_already_me?
746 errors.add(:slug, :reserved_for_trash) if slug == CccConventions::TRASH_SLUG 742 errors.add(:slug, :reserved_for_trash) if slug == CccConventions::TRASH_SLUG
747 errors.add(:staged_slug, :reserved_for_trash) if staged_slug == CccConventions::TRASH_SLUG
748 end 743 end
749 744
750 if persisted? && slug_was == CccConventions::TRASH_SLUG && Node.find(id).trash_node? 745 if persisted? && slug_was == CccConventions::TRASH_SLUG && Node.find(id).trash_node?
751 errors.add(:slug, :trash_immutable) if slug_changed? 746 errors.add(:slug, :trash_immutable) if slug_changed?
752 errors.add(:parent_id, :trash_immutable) if parent_id_changed? 747 errors.add(:parent_id, :trash_immutable) if parent_id_changed?
753 errors.add(:staged_slug, :trash_must_be_empty) if staged_slug.present?
754 errors.add(:staged_parent_id, :trash_must_be_empty) if staged_parent_id.present?
755 end 748 end
756 end 749 end
757 750
diff --git a/app/models/page.rb b/app/models/page.rb
index 8313b1d4..4635f1b4 100644
--- a/app/models/page.rb
+++ b/app/models/page.rb
@@ -20,9 +20,13 @@ class Page < ApplicationRecord
20 :inclusion => { :in => ->(_) { Page.custom_templates } }, 20 :inclusion => { :in => ->(_) { Page.custom_templates } },
21 :allow_blank => true, 21 :allow_blank => true,
22 :if => :template_name_changed? 22 :if => :template_name_changed?
23 validates_format_of :slug, :with => /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/,
24 :unless => -> { slug.blank? }
25 validate :page_slug_not_reserved
23 26
24 # Associations 27 # Associations
25 belongs_to :node, optional: true 28 belongs_to :node, optional: true
29 belongs_to :parent_node, :class_name => "Node", :optional => true
26 belongs_to :user, optional: true 30 belongs_to :user, optional: true
27 belongs_to :editor, :class_name => "User", optional: true 31 belongs_to :editor, :class_name => "User", optional: true
28 has_many :related_assets, :dependent => :destroy 32 has_many :related_assets, :dependent => :destroy
@@ -202,6 +206,8 @@ class Page < ApplicationRecord
202 page.translations.reload 206 page.translations.reload
203 207
204 # Clone untranslated attributes 208 # Clone untranslated attributes
209 self.slug = page.slug
210 self.parent_node_id = page.parent_node_id
205 self.tag_list = page.tag_list 211 self.tag_list = page.tag_list
206 self.template_name ||= page.template_name 212 self.template_name ||= page.template_name
207 self.published_at = page.published_at 213 self.published_at = page.published_at
@@ -292,6 +298,20 @@ class Page < ApplicationRecord
292 published_at.nil? ? true : published_at < Time.now 298 published_at.nil? ? true : published_at < Time.now
293 end 299 end
294 300
301 # The address this page will have once published.
302 def prospective_unique_name
303 return nil if parent_node_id.nil?
304
305 parent = Node.find_by(:id => parent_node_id)
306 return nil unless parent
307
308 [parent.unique_name.presence, slug].compact.join("/")
309 end
310
311 def parent_node_missing?
312 parent_node_id.present? && !Node.exists?(:id => parent_node_id)
313 end
314
295 def effective_lang 315 def effective_lang
296 if translated_locales.empty? 316 if translated_locales.empty?
297 return 'de' 317 return 'de'
@@ -333,6 +353,14 @@ class Page < ApplicationRecord
333 end 353 end
334 end 354 end
335 355
356 def page_slug_not_reserved
357 return unless slug == CccConventions::TRASH_SLUG
358 return if node&.trash_node?
359 return unless parent_node_id && Node.find_by(:id => parent_node_id)&.root?
360
361 errors.add(:slug, :reserved_for_trash)
362 end
363
336 # Installs (or re-installs) the trigger that keeps page_translations' 364 # Installs (or re-installs) the trigger that keeps page_translations'
337 # search_vector in sync. Idempotent, safe to call on every boot. 365 # search_vector in sync. Idempotent, safe to call on every boot.
338 # search_vector is populated by a raw Postgres trigger, not anything 366 # search_vector is populated by a raw Postgres trigger, not anything
diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb
index c153c241..ae206578 100644
--- a/app/views/nodes/edit.html.erb
+++ b/app/views/nodes/edit.html.erb
@@ -96,20 +96,20 @@
96 <div id="metadata"> 96 <div id="metadata">
97 <div class="layout_row_label"><%= t(".slug") %></div> 97 <div class="layout_row_label"><%= t(".slug") %></div>
98 <div class="layout_row_content"> 98 <div class="layout_row_content">
99 <%= f.text_field( 99 <%= d.text_field(
100 :staged_slug, :value => @node.staged_slug || @node.slug 100 :slug, :value => @page.slug || @node.slug
101 ) 101 )
102 %> 102 %>
103 </div> 103 </div>
104 104
105 <div class="layout_row_label"><%= t(".parent") %></div> 105 <div class="layout_row_label"><%= t(".parent") %></div>
106 <div class="layout_row_content"> 106 <div class="layout_row_content">
107 <%= text_field_tag :move_to_search_term, @node.parent.title rescue "" %> 107 <%= text_field_tag :move_to_search_term, (Node.find_by(:id => @page.parent_node_id) || @node.parent)&.title %>
108 <p class="field_hint"><%= t(".parent_hint") %></p> 108 <p class="field_hint"><%= t(".parent_hint") %></p>
109 <div id="move_to_search_results" class="search_results"></div> 109 <div id="move_to_search_results" class="search_results"></div>
110 <%= f.hidden_field( 110 <%= d.hidden_field(
111 :staged_parent_id, 111 :parent_node_id,
112 :value => @node.staged_parent_id || @node.parent_id 112 :value => @page.parent_node_id || @node.parent_id
113 ) 113 )
114 %> 114 %>
115 </div> 115 </div>
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb
index 105e37ea..3b2d28d9 100644
--- a/app/views/nodes/show.html.erb
+++ b/app/views/nodes/show.html.erb
@@ -105,7 +105,7 @@
105 <% end %> 105 <% end %>
106 <div class="info_item"> 106 <div class="info_item">
107 <span class="info_label"><%= t(".restore_to") %></span> 107 <span class="info_label"><%= t(".restore_to") %></span>
108 <% suggestion = @node.suggested_restore_parent %> 108 <% suggestion = @node.draft&.parent_node || @node.suggested_restore_parent %>
109 <%= form_tag restore_from_trash_node_path(@node), :method => :put, :class => "aligned_action_row" do %> 109 <%= form_tag restore_from_trash_node_path(@node), :method => :put, :class => "aligned_action_row" do %>
110 <div class="restore_picker"> 110 <div class="restore_picker">
111 <%= text_field_tag :restore_search_term, 111 <%= text_field_tag :restore_search_term,
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 85cc91c5..6068d4c4 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -112,9 +112,7 @@ de:
112 title: "Titel" 112 title: "Titel"
113 node: 113 node:
114 slug: "Slug" 114 slug: "Slug"
115 staged_slug: "Vorgemerkter Slug"
116 parent_id: "Eltern-Node" 115 parent_id: "Eltern-Node"
117 staged_parent_id: "Vorgemerkter Eltern-Node"
118 head_id: "Head" 116 head_id: "Head"
119 related_asset: 117 related_asset:
120 headline: "Aufmacher" 118 headline: "Aufmacher"
@@ -137,11 +135,16 @@ de:
137 trash_undeletable: "Der Papierkorb-Node kann nicht gelöscht werden" 135 trash_undeletable: "Der Papierkorb-Node kann nicht gelöscht werden"
138 publish_in_trash: "Ein Node im Papierkorb kann nicht veröffentlicht werden" 136 publish_in_trash: "Ein Node im Papierkorb kann nicht veröffentlicht werden"
139 move_under_self: "Ein Node kann nicht unter sich selbst oder einen seiner Nachfahren verschoben werden" 137 move_under_self: "Ein Node kann nicht unter sich selbst oder einen seiner Nachfahren verschoben werden"
138 move_target_missing: "Die Seite, unter die verschoben werden sollte, gibt es nicht mehr."
140 trash_the_trash: "Der Papierkorb-Node selbst kann nicht in den Papierkorb verschoben werden" 139 trash_the_trash: "Der Papierkorb-Node selbst kann nicht in den Papierkorb verschoben werden"
141 restore_target_invalid: "Das Wiederherstellungsziel muss ein lebender Node sein" 140 restore_target_invalid: "Das Wiederherstellungsziel muss ein lebender Node sein"
142 destroy_outside_trash: "Nodes können nur aus dem Papierkorb gelöscht werden" 141 destroy_outside_trash: "Nodes können nur aus dem Papierkorb gelöscht werden"
143 attach_in_trash: "An einen Node im Papierkorb können keine Assets angehängt werden" 142 attach_in_trash: "An einen Node im Papierkorb können keine Assets angehängt werden"
144 not_permitted: "In diesem Bereich dürfen nur Mitglieder der Redaktion veröffentlichte Inhalte ändern" 143 not_permitted: "In diesem Bereich dürfen nur Mitglieder der Redaktion veröffentlichte Inhalte ändern"
144 page:
145 attributes:
146 slug:
147 reserved_for_trash: "ist für den Papierkorb reserviert"
145 user: 148 user:
146 attributes: 149 attributes:
147 roles: 150 roles:
@@ -676,7 +679,6 @@ de:
676 trashed: "Seite wurde in den Papierkorb verschoben" 679 trashed: "Seite wurde in den Papierkorb verschoben"
677 already_trashed: "Seite ist bereits im Papierkorb" 680 already_trashed: "Seite ist bereits im Papierkorb"
678 restored: "Seite wurde aus dem Papierkorb wiederhergestellt" 681 restored: "Seite wurde aus dem Papierkorb wiederhergestellt"
679 restore_target_missing: "Wiederherstellungsziel nicht gefunden"
680 deleted: "Seite wurde endgültig gelöscht" 682 deleted: "Seite wurde endgültig gelöscht"
681 published: "Entwurf wurde veröffentlicht" 683 published: "Entwurf wurde veröffentlicht"
682 unlocked: "Node entsperrt" 684 unlocked: "Node entsperrt"
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 6cff43f0..524d07d0 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -63,9 +63,7 @@ en:
63 title: "Title" 63 title: "Title"
64 node: 64 node:
65 slug: "Slug" 65 slug: "Slug"
66 staged_slug: "Staged slug"
67 parent_id: "Parent node" 66 parent_id: "Parent node"
68 staged_parent_id: "Staged parent node"
69 head_id: "Head" 67 head_id: "Head"
70 related_asset: 68 related_asset:
71 headline: "Headline" 69 headline: "Headline"
@@ -83,11 +81,16 @@ en:
83 trash_undeletable: "The Trash node cannot be destroyed" 81 trash_undeletable: "The Trash node cannot be destroyed"
84 publish_in_trash: "Cannot publish a node in the Trash" 82 publish_in_trash: "Cannot publish a node in the Trash"
85 move_under_self: "Cannot move a node under itself or one of its own descendants" 83 move_under_self: "Cannot move a node under itself or one of its own descendants"
84 move_target_missing: "The page this was to be moved under no longer exists."
86 trash_the_trash: "The Trash node itself cannot be trashed" 85 trash_the_trash: "The Trash node itself cannot be trashed"
87 restore_target_invalid: "Restore target must be a living node" 86 restore_target_invalid: "Restore target must be a living node"
88 destroy_outside_trash: "Nodes are only destroyed from the Trash" 87 destroy_outside_trash: "Nodes are only destroyed from the Trash"
89 attach_in_trash: "Cannot attach assets to a node in the Trash" 88 attach_in_trash: "Cannot attach assets to a node in the Trash"
90 not_permitted: "Only Redaktion members may change published content in this section" 89 not_permitted: "Only Redaktion members may change published content in this section"
90 page:
91 attributes:
92 slug:
93 reserved_for_trash: "is reserved for the Trash"
91 user: 94 user:
92 attributes: 95 attributes:
93 roles: 96 roles:
@@ -639,7 +642,6 @@ en:
639 trashed: "Page has been moved to the Trash" 642 trashed: "Page has been moved to the Trash"
640 already_trashed: "Page is already in the Trash" 643 already_trashed: "Page is already in the Trash"
641 restored: "Page has been restored from the Trash" 644 restored: "Page has been restored from the Trash"
642 restore_target_missing: "Restore target not found"
643 deleted: "Page has been permanently deleted" 645 deleted: "Page has been permanently deleted"
644 published: "Draft has been published" 646 published: "Draft has been published"
645 unlocked: "Node unlocked" 647 unlocked: "Node unlocked"
diff --git a/db/migrate/20260807010722_add_address_to_pages.rb b/db/migrate/20260807010722_add_address_to_pages.rb
new file mode 100644
index 00000000..12cf89bd
--- /dev/null
+++ b/db/migrate/20260807010722_add_address_to_pages.rb
@@ -0,0 +1,6 @@
1class AddAddressToPages < ActiveRecord::Migration[8.1]
2 def change
3 add_column :pages, :slug, :string
4 add_column :pages, :parent_node_id, :integer
5 end
6end
diff --git a/lib/tasks/pages.rake b/lib/tasks/pages.rake
new file mode 100644
index 00000000..0cfbc5dc
--- /dev/null
+++ b/lib/tasks/pages.rake
@@ -0,0 +1,33 @@
1namespace :pages do
2 desc "Backfill pages.slug and pages.parent_node_id from each page's " \
3 "node. Historical accuracy is not attempted. Every revision gets " \
4 "the node's current address, which is right for head and draft and " \
5 "harmless for older revisions, and avoids nil checks everywhere. " \
6 "Dry run unless WRITE=1."
7 task :backfill_address => :environment do
8 write = ENV["WRITE"] == "1"
9 puts "DRY RUN -- nothing written. Re-run with WRITE=1." unless write
10
11 touched = 0
12 Node.find_each do |node|
13 scope = node.pages.where("slug IS DISTINCT FROM :s OR parent_node_id IS DISTINCT FROM :p",
14 :s => node.slug, :p => node.parent_id)
15 count = scope.count
16 next if count.zero?
17
18 scope.update_all(:slug => node.slug, :parent_node_id => node.parent_id) if write
19 touched += count
20 end
21
22 # Autosaves carry no node_id -- has_many :pages does not cover them.
23 Node.where.not(:autosave_id => nil).includes(:autosave).find_each do |node|
24 a = node.autosave
25 next if a.slug == node.slug && a.parent_node_id == node.parent_id
26
27 a.update_columns(:slug => node.slug, :parent_node_id => node.parent_id) if write
28 touched += 1
29 end
30
31 puts "#{write ? "updated" : "would update"} #{touched} pages"
32 end
33end
diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js
index 37b696e4..852f5339 100644
--- a/public/javascripts/admin_search.js
+++ b/public/javascripts/admin_search.js
@@ -257,7 +257,7 @@ move_to_search = {
257 showRestricted: true, 257 showRestricted: true,
258 onSelect: function(node) { 258 onSelect: function(node) {
259 $("#move_to_search_term").val(node.title); 259 $("#move_to_search_term").val(node.title);
260 $("#node_staged_parent_id").val(node.node_id); 260 $("#page_parent_node_id").val(node.node_id);
261 } 261 }
262 }); 262 });
263 } 263 }
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index 08a19a79..889a5729 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -1566,7 +1566,7 @@ form.button_to button[type="submit"] {
1566} 1566}
1567 1567
1568input[type=text]#tag_list, 1568input[type=text]#tag_list,
1569input[type=text]#node_staged_slug, 1569input[type=text]#page_slug,
1570input#move_to_search_term { 1570input#move_to_search_term {
1571 padding: 5px; 1571 padding: 5px;
1572} 1572}
@@ -1644,7 +1644,7 @@ input[type=text]#page_title {
1644} 1644}
1645 1645
1646input#tag_list, 1646input#tag_list,
1647input#node_staged_slug, 1647input#page_slug,
1648input#move_to_search_term { 1648input#move_to_search_term {
1649 box-sizing: border-box; 1649 box-sizing: border-box;
1650 width: 100%; 1650 width: 100%;
diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb
index 745ae4e0..c7caeed1 100644
--- a/test/controllers/nodes_controller_test.rb
+++ b/test/controllers/nodes_controller_test.rb
@@ -193,7 +193,8 @@ class NodesControllerTest < ActionController::TestCase
193 test "publish draft with staged_slug unqueal slug" do 193 test "publish draft with staged_slug unqueal slug" do
194 login_as :quentin 194 login_as :quentin
195 195
196 test_node = Node.root.children.create! :slug => "test_node", :staged_slug => "peter_pan" 196 test_node = Node.root.children.create!(:slug => "test_node")
197 test_node.draft.update!(:slug => "peter_pan")
197 198
198 put :publish, params: { :id => test_node.id } 199 put :publish, params: { :id => test_node.id }
199 200
@@ -205,7 +206,8 @@ class NodesControllerTest < ActionController::TestCase
205 test "publish draft with staged_slug with more levels of nodes" do 206 test "publish draft with staged_slug with more levels of nodes" do
206 login_as :quentin 207 login_as :quentin
207 208
208 test_node = Node.root.children.create! :slug => "test_node", :staged_slug => "peter_pan" 209 test_node = Node.root.children.create!(:slug => "test_node")
210 test_node.draft.update!(:slug => "peter_pan")
209 test_node2 = test_node.children.create! :slug => "test_node2" 211 test_node2 = test_node.children.create! :slug => "test_node2"
210 212
211 put :publish, params: { :id => test_node.id } 213 put :publish, params: { :id => test_node.id }
@@ -215,12 +217,13 @@ class NodesControllerTest < ActionController::TestCase
215 assert_equal "peter_pan", test_node.unique_name 217 assert_equal "peter_pan", test_node.unique_name
216 end 218 end
217 219
218 test "publish draft with staged_parent_id" do 220 test "publish draft with a moved parent" do
219 login_as :quentin 221 login_as :quentin
220 222
221 parent = Node.root.children.create! :slug => "parent" 223 parent = Node.root.children.create!(:slug => "parent")
222 test_node = Node.root.children.create! :slug => "test_node", :staged_parent_id => parent.id 224 test_node = Node.root.children.create!(:slug => "test_node")
223 test_node2 = test_node.children.create! :slug => "test_node2" 225 test_node.draft.update!(:parent_node_id => parent.id)
226 test_node2 = test_node.children.create!(:slug => "test_node2")
224 227
225 put :publish, params: { :id => test_node.id } 228 put :publish, params: { :id => test_node.id }
226 229
@@ -229,18 +232,13 @@ class NodesControllerTest < ActionController::TestCase
229 assert_equal "parent/test_node/test_node2", test_node2.unique_name 232 assert_equal "parent/test_node/test_node2", test_node2.unique_name
230 end 233 end
231 234
232 test "publish draft with staged_parent_id and staged_slug" do 235 test "publish draft with a moved parent and a renamed slug" do
233 login_as :quentin 236 login_as :quentin
234 237
235 parent = Node.root.children.create! :slug => "parent" 238 parent = Node.root.children.create!(:slug => "parent")
236 239 test_node = Node.root.children.create!(:slug => "test_node")
237 test_node = Node.root.children.create!( 240 test_node.draft.update!(:parent_node_id => parent.id, :slug => "peter_pan")
238 :slug => "test_node", 241 test_node2 = test_node.children.create!(:slug => "test_node2")
239 :staged_parent_id => parent.id,
240 :staged_slug => "peter_pan"
241 )
242
243 test_node2 = test_node.children.create! :slug => "test_node2"
244 242
245 put :publish, params: { :id => test_node.id } 243 put :publish, params: { :id => test_node.id }
246 244
@@ -293,7 +291,7 @@ class NodesControllerTest < ActionController::TestCase
293 291
294 other_node = Node.root.children.create( :slug => "other" ) 292 other_node = Node.root.children.create( :slug => "other" )
295 293
296 node.staged_parent_id = other_node.id 294 node.draft.update!(:parent_node_id => other_node.id)
297 node.publish_draft! 295 node.publish_draft!
298 296
299 assert Node.valid? 297 assert Node.valid?
@@ -711,18 +709,33 @@ class NodesControllerTest < ActionController::TestCase
711 assert flash[:error].present? 709 assert flash[:error].present?
712 end 710 end
713 711
714 test "restore_from_trash reparents to the given parent" do 712 test "restore_from_trash reparents to the 'old' parent" do
715 login_as :quentin 713 login_as :quentin
716 node = Node.root.children.create!(:slug => "restore_me") 714 node = Node.root.children.create!(:slug => "restore_me")
717 node.trash!(users(:quentin)) 715 node.trash!(users(:quentin))
718 target = Node.root.children.create!(:slug => "restore_home") 716 target = Node.root.children.create!(:slug => "restore_home")
719 717
720 put :restore_from_trash, params: { :id => node.id, :parent_id => target.id } 718 node.reload.draft.update!(:parent_node_id => target.id)
719 put :restore_from_trash, params: { :locale => "de", :id => node.id }
721 720
722 assert_redirected_to node_path(node) 721 assert_redirected_to node_path(node)
723 assert_equal target, node.reload.parent 722 assert_equal target, node.reload.parent
724 end 723 end
725 724
725 test "restore_from_trash follows an explicitly chosen parent" do
726 login_as :quentin
727 node = Node.root.children.create!(:slug => "restore_pick")
728 node.trash!(users(:quentin))
729 chosen = Node.root.children.create!(:slug => "chosen_home")
730
731 put :restore_from_trash, params: { :locale => "de", :id => node.id,
732 :parent_id => chosen.id }
733
734 assert_equal chosen, node.reload.parent
735 assert_equal chosen.id, node.draft.parent_node_id,
736 "the choice is recorded on the draft, not applied behind its back"
737 end
738
726 test "destroy refuses a node outside the Trash" do 739 test "destroy refuses a node outside the Trash" do
727 login_as :quentin 740 login_as :quentin
728 node = Node.root.children.create!(:slug => "not_deletable_here") 741 node = Node.root.children.create!(:slug => "not_deletable_here")
diff --git a/test/models/node_test.rb b/test/models/node_test.rb
index c7fee58e..df1e96cb 100644
--- a/test/models/node_test.rb
+++ b/test/models/node_test.rb
@@ -488,7 +488,7 @@ class NodeTest < ActiveSupport::TestCase
488 a = Node.root.children.create!(:slug => "cycle_guard_a") 488 a = Node.root.children.create!(:slug => "cycle_guard_a")
489 b = a.children.create!(:slug => "cycle_guard_b") 489 b = a.children.create!(:slug => "cycle_guard_b")
490 490
491 a.staged_parent_id = b.id 491 a.draft.update!(:parent_node_id => b.id)
492 492
493 assert_raises(ActiveRecord::RecordInvalid) { a.publish_draft! } 493 assert_raises(ActiveRecord::RecordInvalid) { a.publish_draft! }
494 494
@@ -702,16 +702,15 @@ class NodeTest < ActiveSupport::TestCase
702 assert_equal "New Title", action.metadata.dig("title", "to") 702 assert_equal "New Title", action.metadata.dig("title", "to")
703 end 703 end
704 704
705 test "publishing a staged slug change logs a move with the path pair" do 705 test "publishing a slug change logs a move with the path pair" do
706 node = create_node_with_published_page 706 node = create_node_with_published_page
707 path_before = node.unique_name 707 path_before = node.unique_name
708 node.staged_slug = "moved-#{node.slug}" 708 find_or_create_draft(node, @user1)
709 node.save! 709 node.draft.update!(:slug => "moved-#{node.slug}")
710 publish_count_before = NodeAction.where(:action => "publish").count
711 710
712 node.publish_draft!(@user1) 711 node.publish_draft!(@user1)
713
714 node.reload 712 node.reload
713
715 assert_not_equal path_before, node.unique_name 714 assert_not_equal path_before, node.unique_name
716 715
717 action = NodeAction.where(:action => "move").last 716 action = NodeAction.where(:action => "move").last
@@ -719,16 +718,12 @@ class NodeTest < ActiveSupport::TestCase
719 assert_equal @user1, action.user 718 assert_equal @user1, action.user
720 assert_equal path_before, action.metadata.dig("path", "from") 719 assert_equal path_before, action.metadata.dig("path", "from")
721 assert_equal node.unique_name, action.metadata.dig("path", "to") 720 assert_equal node.unique_name, action.metadata.dig("path", "to")
722
723 # No draft was pending: path change alone must not fabricate a publish.
724 assert_equal publish_count_before, NodeAction.where(:action => "publish").count
725 end 721 end
726 722
727 test "publishing a draft together with a staged move logs two entries" do 723 test "publishing a draft together with a move logs two entries" do
728 node = create_node_with_published_page 724 node = create_node_with_published_page
729 find_or_create_draft(node, @user1) 725 find_or_create_draft(node, @user1)
730 node.staged_slug = "relocated-#{node.slug}" 726 node.draft.update!(:slug => "relocated-#{node.slug}")
731 node.save!
732 727
733 assert_difference "NodeAction.count", 2 do 728 assert_difference "NodeAction.count", 2 do
734 node.publish_draft!(@user1) 729 node.publish_draft!(@user1)
@@ -890,7 +885,8 @@ class NodeTest < ActiveSupport::TestCase
890 Node.trash 885 Node.trash
891 886
892 assert_not Node.root.children.build(:slug => CccConventions::TRASH_SLUG).valid? 887 assert_not Node.root.children.build(:slug => CccConventions::TRASH_SLUG).valid?
893 assert_not Node.root.children.build(:slug => "fine", :staged_slug => CccConventions::TRASH_SLUG).valid? 888 page = Page.new(:slug => CccConventions::TRASH_SLUG, :parent_node_id => Node.root.id)
889 assert_not page.valid?
894 assert Node.trash.children.create!(:slug => "sub").children.build(:slug => CccConventions::TRASH_SLUG).valid? 890 assert Node.trash.children.create!(:slug => "sub").children.build(:slug => CccConventions::TRASH_SLUG).valid?
895 end 891 end
896 892
@@ -1012,7 +1008,7 @@ class NodeTest < ActiveSupport::TestCase
1012 year = updates.children.create!(:slug => "2026") 1008 year = updates.children.create!(:slug => "2026")
1013 node = Node.root.children.create!(:slug => "outside-post") 1009 node = Node.root.children.create!(:slug => "outside-post")
1014 node.reload.draft.update!(:title => "Entwurf") 1010 node.reload.draft.update!(:title => "Entwurf")
1015 node.update!(:staged_parent_id => year.id) 1011 node.draft.update!(:parent_node_id => year.id)
1016 1012
1017 assert_raises(ActiveRecord::RecordInvalid) { node.publish_draft!(editor) } 1013 assert_raises(ActiveRecord::RecordInvalid) { node.publish_draft!(editor) }
1018 assert_nil node.reload.head 1014 assert_nil node.reload.head
@@ -1025,7 +1021,7 @@ class NodeTest < ActiveSupport::TestCase
1025 club = Node.root.children.create!(:slug => "club") 1021 club = Node.root.children.create!(:slug => "club")
1026 node = Node.root.children.create!(:slug => "movable-post") 1022 node = Node.root.children.create!(:slug => "movable-post")
1027 node.reload.draft.update!(:title => "Entwurf") 1023 node.reload.draft.update!(:title => "Entwurf")
1028 node.update!(:staged_parent_id => club.id) 1024 node.draft.update!(:parent_node_id => club.id)
1029 1025
1030 node.publish_draft!(editor) 1026 node.publish_draft!(editor)
1031 assert_equal club.id, node.reload.parent_id 1027 assert_equal club.id, node.reload.parent_id
@@ -1036,7 +1032,7 @@ class NodeTest < ActiveSupport::TestCase
1036 :password => "secret", :password_confirmation => "secret") 1032 :password => "secret", :password_confirmation => "secret")
1037 node = Node.root.children.create!(:slug => "harmless") 1033 node = Node.root.children.create!(:slug => "harmless")
1038 node.reload.draft.update!(:title => "Entwurf") 1034 node.reload.draft.update!(:title => "Entwurf")
1039 node.update!(:staged_slug => "updates") 1035 node.draft.update!(:slug => "updates")
1040 1036
1041 assert_raises(ActiveRecord::RecordInvalid) { node.publish_draft!(editor) } 1037 assert_raises(ActiveRecord::RecordInvalid) { node.publish_draft!(editor) }
1042 assert_nil node.reload.head 1038 assert_nil node.reload.head
@@ -1057,7 +1053,9 @@ class NodeTest < ActiveSupport::TestCase
1057 node = Node.root.children.create!(:slug => "restorable") 1053 node = Node.root.children.create!(:slug => "restorable")
1058 node.reload.trash! 1054 node.reload.trash!
1059 1055
1060 assert_raises(ActiveRecord::RecordInvalid) { node.restore_from_trash!(updates, editor) } 1056 node.reload.draft.update!(:parent_node_id => updates.id)
1057
1058 assert_raises(ActiveRecord::RecordInvalid) { node.restore_from_trash!(editor) }
1061 assert node.reload.in_trash? 1059 assert node.reload.in_trash?
1062 end 1060 end
1063 1061
@@ -1068,7 +1066,8 @@ class NodeTest < ActiveSupport::TestCase
1068 node = Node.root.children.create!(:slug => "restorable_free") 1066 node = Node.root.children.create!(:slug => "restorable_free")
1069 node.reload.trash! 1067 node.reload.trash!
1070 1068
1071 node.restore_from_trash!(club, editor) 1069 node.reload.draft.update!(:parent_node_id => club.id)
1070 node.restore_from_trash!(editor)
1072 assert_not node.reload.in_trash? 1071 assert_not node.reload.in_trash?
1073 assert_equal club.id, node.parent_id 1072 assert_equal club.id, node.parent_id
1074 end 1073 end
@@ -1101,4 +1100,65 @@ class NodeTest < ActiveSupport::TestCase
1101 node.update_external_url!("https://example.org", users(:quentin)) 1100 node.update_external_url!("https://example.org", users(:quentin))
1102 end 1101 end
1103 end 1102 end
1103
1104 test "a new node's draft carries the node's address" do
1105 parent = Node.root.children.create!(:slug => "addr_parent")
1106 node = parent.children.create!(:slug => "addr_child")
1107
1108 assert_equal "addr_child", node.draft.slug
1109 assert_equal parent.id, node.draft.parent_node_id
1110 end
1111
1112 test "an autosave inherits the draft's address" do
1113 node = Node.root.children.create!(:slug => "addr_autosave")
1114 node.draft.update!(:slug => "renamed")
1115 node.lock_for_editing!(users(:quentin))
1116 node.autosave!({ :title => "x" }, users(:quentin))
1117
1118 assert_equal "renamed", node.autosave.slug
1119 end
1120
1121 test "a blank slug in an autosave leaves the address unchanged" do
1122 node = Node.root.children.create!(:slug => "addr_blank")
1123 node.lock_for_editing!(users(:quentin))
1124 node.autosave!({ :slug => "", :title => "x" }, users(:quentin))
1125
1126 assert_equal "addr_blank", node.autosave.slug
1127 end
1128
1129 test "a node with no draft has nothing to publish" do
1130 node = create_node_with_published_page
1131 assert_nil node.draft
1132
1133 assert_nil node.publish_draft!(@user1)
1134 end
1135
1136 test "prospective_unique_name reports the draft's intended address" do
1137 parent = Node.root.children.create!(:slug => "prospective_parent")
1138 node = Node.root.children.create!(:slug => "prospective_child")
1139 node.draft.update!(:parent_node_id => parent.id)
1140
1141 assert_equal "prospective_parent/prospective_child",
1142 node.prospective_unique_name
1143 end
1144
1145 test "prospective_unique_name falls back to the live address when the parent is gone" do
1146 parent = Node.root.children.create!(:slug => "doomed_parent")
1147 node = Node.root.children.create!(:slug => "orphan_child")
1148 node.draft.update!(:parent_node_id => parent.id)
1149 parent.destroy!
1150
1151 assert node.reload.draft.parent_node_missing?
1152 assert_equal "orphan_child", node.prospective_unique_name
1153 end
1154
1155 test "publishing a draft whose parent is gone is refused" do
1156 parent = Node.root.children.create!(:slug => "doomed_parent_two")
1157 node = Node.root.children.create!(:slug => "orphan_child_two")
1158 node.draft.update!(:parent_node_id => parent.id)
1159 parent.destroy!
1160
1161 assert_raises(ActiveRecord::RecordInvalid) { node.reload.publish_draft! }
1162 assert_nil node.reload.head
1163 end
1104end 1164end
diff --git a/test/models/node_trash_test.rb b/test/models/node_trash_test.rb
index 3947f20e..a4774c58 100644
--- a/test/models/node_trash_test.rb
+++ b/test/models/node_trash_test.rb
@@ -76,7 +76,8 @@ class NodeTrashTest < ActiveSupport::TestCase
76 node.trash!(@user1) 76 node.trash!(@user1)
77 target = Node.root.children.create!(:slug => "restore_target") 77 target = Node.root.children.create!(:slug => "restore_target")
78 78
79 node.reload.restore_from_trash!(target, @user1) 79 node.reload.draft.update!(:parent_node_id => target.id)
80 node.reload.restore_from_trash!(@user1)
80 node.reload 81 node.reload
81 82
82 assert_equal target, node.parent 83 assert_equal target, node.parent
@@ -91,8 +92,11 @@ class NodeTrashTest < ActiveSupport::TestCase
91 node.trash!(@user1) 92 node.trash!(@user1)
92 other_trashed = Node.trash.children.create!(:slug => "also_trashed") 93 other_trashed = Node.trash.children.create!(:slug => "also_trashed")
93 94
94 assert_raises(ActiveRecord::RecordInvalid) { node.reload.restore_from_trash!(Node.trash, @user1) } 95 node.reload.draft.update!(:parent_node_id => Node.trash.id)
95 assert_raises(ActiveRecord::RecordInvalid) { node.reload.restore_from_trash!(other_trashed, @user1) } 96 assert_raises(ActiveRecord::RecordInvalid) { node.reload.restore_from_trash!(@user1) }
97
98 node.reload.draft.update!(:parent_node_id => other_trashed.id)
99 assert_raises(ActiveRecord::RecordInvalid) { node.reload.restore_from_trash!(@user1) }
96 end 100 end
97 101
98 test "destroy_from_trash! refuses nodes outside the Trash" do 102 test "destroy_from_trash! refuses nodes outside the Trash" do