summaryrefslogtreecommitdiff
path: root/app
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 /app
parent02a4ea750428aa1a9c9e7f2680553c0ce4ef1fec (diff)
Move the pending address from the node onto the draft
Diffstat (limited to 'app')
-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
5 files changed, 64 insertions, 48 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,