summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-18 02:15:29 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-18 02:15:29 +0200
commitd41ee504caedbe858e24ab2a23c7a804454c64c8 (patch)
treeeb3ec7c8f44d15fea40292d0a9e7358e41640edc /app
parent5dced8b4b624aabf4215ba21b13957080345c326 (diff)
Add Trash affordances: cockpit, listing, dashboard entry
nodes#show gains a Trash section on trashed nodes: provenance from the trash entry, a restore form whose parent picker pre-fills the old parent while it still lives, and permanent deletion. A Move-to-Trash button joins the status actions on living nodes. nodes#trashed lists trashed subtree roots with weight, provenance, and deletion; the dashboard housekeeping row links to it, and trash/destroy redirect there. Deletion from Trash now removes the whole subtree, deepest first, each node through a real destroy! so every per-node cascade runs -- amending the never-recursive rule for this one sanctioned path (both confirms state the count; the root entry carries destroyed_descendants). Bare Node#destroy still refuses children.
Diffstat (limited to 'app')
-rw-r--r--app/controllers/nodes_controller.rb10
-rw-r--r--app/models/node.rb43
-rw-r--r--app/models/node_action.rb2
-rw-r--r--app/views/admin/index.html.erb4
-rw-r--r--app/views/nodes/show.html.erb53
-rw-r--r--app/views/nodes/trashed.html.erb42
6 files changed, 145 insertions, 9 deletions
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index 9ea66ad..021f8ff 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -144,7 +144,7 @@ class NodesController < ApplicationController
144 def trash 144 def trash
145 if @node.trash!(current_user) 145 if @node.trash!(current_user)
146 flash[:notice] = "Page has been moved to the Trash" 146 flash[:notice] = "Page has been moved to the Trash"
147 redirect_to node_path(Node.trash) 147 redirect_to trashed_nodes_path
148 else 148 else
149 flash[:notice] = "Page is already in the Trash" 149 flash[:notice] = "Page is already in the Trash"
150 redirect_to node_path(@node) 150 redirect_to node_path(@node)
@@ -170,7 +170,8 @@ class NodesController < ApplicationController
170 def destroy 170 def destroy
171 @node.destroy_from_trash!(current_user) 171 @node.destroy_from_trash!(current_user)
172 flash[:notice] = "Page has been permanently deleted" 172 flash[:notice] = "Page has been permanently deleted"
173 redirect_to node_path(Node.trash) 173 redirect_to trashed_nodes_path
174
174 rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed => e 175 rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed => e
175 flash[:error] = e.message 176 flash[:error] = e.message
176 redirect_to node_path(@node) 177 redirect_to node_path(@node)
@@ -247,6 +248,11 @@ class NodesController < ApplicationController
247 @sitemap_descendant_counts = descendant_counts_for(@sitemap) 248 @sitemap_descendant_counts = descendant_counts_for(@sitemap)
248 end 249 end
249 250
251 def trashed
252 @nodes = Node.trash.children.order(:slug)
253 .paginate(:page => params[:page], :per_page => 50)
254 end
255
250 private 256 private
251 257
252 def slug_for(title) 258 def slug_for(title)
diff --git a/app/models/node.rb b/app/models/node.rb
index 4dae287..8d0756a 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -4,6 +4,7 @@ class Node < ApplicationRecord
4 4
5 # Associations 5 # Associations
6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy 6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy
7 has_many :node_actions, :dependent => :nullify
7 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, optional: true 8 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, optional: true
8 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, optional: true 9 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, optional: true
9 # Autosave pages carry no node_id, so has_many :pages does not cover 10 # Autosave pages carry no node_id, so has_many :pages does not cover
@@ -351,19 +352,49 @@ class Node < ApplicationRecord
351 end 352 end
352 end 353 end
353 354
354 # Final deletion. Only from inside the Trash, never with children. 355 # Final deletion -- only from inside the Trash. Removes the whole
355 # The log entry is written first, in the same transaction; node_id 356 # subtree, deepest first, each node through a real destroy! so every
356 # nullifies when the row dies, and the metadata carries what remains. 357 # per-node cascade runs (the categorical difference from the old
358 # delete_all nuke). refuse_destroy_with_children on bare destroy is
359 # untouched
360 # One log entry at the root, per the subtree rule, written before the
361 # rows die.
357 def destroy_from_trash! current_user = nil 362 def destroy_from_trash! current_user = nil
358 raise ActiveRecord::RecordInvalid.new(self), "Nodes are only destroyed from the Trash" unless in_trash? 363 raise ActiveRecord::RecordInvalid.new(self), "Nodes are only destroyed from the Trash" unless in_trash?
359 364
360 ActiveRecord::Base.transaction do 365 ActiveRecord::Base.transaction do
361 NodeAction.record!(:node => self, :user => current_user, :action => "destroy", 366 doomed = self_and_descendants_ordered_with_level
362 :path => unique_name) 367 .sort_by { |_, level| -level }
363 destroy! 368 .map(&:first)
369
370 metadata = { :path => unique_name }
371 metadata[:destroyed_descendants] = doomed.size - 1 if doomed.size > 1
372
373 NodeAction.record!(:node => self, :user => current_user, :action => "destroy", **metadata)
374 doomed.each(&:destroy!)
364 end 375 end
365 end 376 end
366 377
378 # The most recent trash entry. Its path.from is the restore hint.
379 def last_trash_entry
380 node_actions.where(:action => "trash").order(:occurred_at => :desc, :id => :desc).first
381 end
382
383 # The node's pre-trash parent, if a living node still answers to
384 # that path. Nil when the parent was itself trashed (its unique_name
385 # changed) or deleted; a different node that has since taken over
386 # the path is a legitimate suggestion.
387 def suggested_restore_parent
388 from = last_trash_entry&.metadata&.dig("path", "from")
389 return nil unless from
390
391 parent_path = from.rpartition("/").first
392 return Node.root if parent_path.empty?
393
394 candidate = Node.find_by(:unique_name => parent_path)
395 candidate unless candidate.nil? || candidate.in_trash? || candidate.trash_node?
396 end
397
367 # returns an array with all parts of a unique_name rather than a string 398 # returns an array with all parts of a unique_name rather than a string
368 def unique_path 399 def unique_path
369 unique_name.to_s.split("/") 400 unique_name.to_s.split("/")
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
index 6e09b5b..13bd5ba 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -65,6 +65,8 @@ class NodeAction < ApplicationRecord
65 # "destroy" (only from inside the Trash, never with children; the 65 # "destroy" (only from inside the Trash, never with children; the
66 # entry is written in the same transaction before the row dies): 66 # entry is written in the same transaction before the row dies):
67 # "path" -- final path, flat string (create-symmetric) 67 # "path" -- final path, flat string (create-symmetric)
68 # "destroyed_descendants" -- integer, only when positive; one entry
69 # at the root, per the subtree rule.
68 # 70 #
69 # Reserved: "demote" (via "trash" | "depublish") for an explicit 71 # Reserved: "demote" (via "trash" | "depublish") for an explicit
70 # depublish workflow, if ever built. 72 # depublish workflow, if ever built.
diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb
index 7933cba..319530d 100644
--- a/app/views/admin/index.html.erb
+++ b/app/views/admin/index.html.erb
@@ -68,5 +68,9 @@
68 <%= link_to menu_items_path, class: "action_button" do %> 68 <%= link_to menu_items_path, class: "action_button" do %>
69 <%= icon("menu-2", library: "tabler", "aria-hidden": true) %> Navigation 69 <%= icon("menu-2", library: "tabler", "aria-hidden": true) %> Navigation
70 <% end %> 70 <% end %>
71 <% trash_count = Node.trash.children.count %>
72 <%= link_to trashed_nodes_path, class: "action_button" do %>
73 <%= icon("trash", library: "tabler", "aria-hidden": true) %> Trash<%= " (#{trash_count})" if trash_count > 0 %>
74 <% end %>
71 </div> 75 </div>
72</div> 76</div>
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb
index ae25571..4d4f6d5 100644
--- a/app/views/nodes/show.html.erb
+++ b/app/views/nodes/show.html.erb
@@ -46,7 +46,7 @@
46 <% end %> 46 <% end %>
47 47
48 <% unless locked_by_other %> 48 <% unless locked_by_other %>
49 <% if @node.draft && !@node.autosave %> 49 <% if @node.draft && !@node.autosave && !@node.in_trash? && !@node.trash_node? %>
50 <div class="node_info_item"> 50 <div class="node_info_item">
51 <%= button_to 'Publish', publish_node_path(@node), method: :put, 51 <%= button_to 'Publish', publish_node_path(@node), method: :put,
52 form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %> 52 form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %>
@@ -62,6 +62,15 @@
62 <% end %> 62 <% end %>
63 </div> 63 </div>
64 <% end %> 64 <% end %>
65 <% unless @node.trash_node? || @node.in_trash? || @node.root? %>
66 <div class="node_info_item">
67 <%= button_to trash_node_path(@node), method: :put,
68 form: { data: { confirm: "Move this page and everything beneath it to the Trash? All published content in it will go offline." }, class: 'button_to destructive' } do %>
69 <%= icon("trash", library: "tabler", "aria-hidden": true) %>
70 Move to Trash
71 <% end %>
72 </div>
73 <% end %>
65 <% end %> 74 <% end %>
66 </div> 75 </div>
67 76
@@ -70,6 +79,48 @@
70 <% end %> 79 <% end %>
71 </div> 80 </div>
72 81
82 <% if @node.in_trash? %>
83 <div class="node_description">Trash</div>
84 <div class="node_content node_info_group">
85 <div class="node_info_group_items">
86 <% if (entry = @node.last_trash_entry) && entry.metadata.dig("path", "from") %>
87 <div class="node_info_item">
88 <span class="node_info_label">Was at</span>
89 <%= entry.metadata.dig("path", "from") %>
90 </div>
91 <% end %>
92 <div class="node_info_item">
93 <span class="node_info_label">Restore to</span>
94 <% suggestion = @node.suggested_restore_parent %>
95 <%= form_tag restore_from_trash_node_path(@node), :method => :put, :class => "aligned_action_row" do %>
96 <div class="restore_picker">
97 <%= text_field_tag :restore_search_term,
98 suggestion && (suggestion.head&.title || suggestion.draft&.title || suggestion.slug),
99 :placeholder => "Search for a new parent…" %>
100 <div id="restore_search_results" class="search_results"></div>
101 </div>
102 <%= hidden_field_tag :parent_id, suggestion&.id %>
103 <%= submit_tag "Restore here", :class => "action_button action_button_no_margin_top" %>
104 <% end %>
105 <span class="field_hint">Restored pages come back unpublished. Republish each page deliberately.</span>
106 </div>
107 </div>
108 <div class="node_info_group_items">
109 <div class="node_info_item">
110 <% doomed_below = @node.descendants.count %>
111 <%= button_to node_path(@node), method: :delete,
112 form: { data: { confirm: doomed_below > 0 ?
113 "Delete this page and the #{doomed_below} pages beneath it permanently? This cannot be undone." :
114 "Delete this page permanently? This cannot be undone." },
115 class: 'button_to destructive' } do %>
116 <%= icon("trash-x", library: "tabler", "aria-hidden": true) %>
117 Delete permanently<%= " (including #{doomed_below} beneath)" if doomed_below > 0 %>
118 <% end %>
119 </div>
120 </div>
121 </div>
122 <% end %>
123
73 <div class="node_description">Translations</div> 124 <div class="node_description">Translations</div>
74 <div class="node_content node_info_group"> 125 <div class="node_content node_info_group">
75 <div class="node_info_group_items"> 126 <div class="node_info_group_items">
diff --git a/app/views/nodes/trashed.html.erb b/app/views/nodes/trashed.html.erb
new file mode 100644
index 0000000..9a4808e
--- /dev/null
+++ b/app/views/nodes/trashed.html.erb
@@ -0,0 +1,42 @@
1<h1>Trash</h1>
2
3<% if @nodes.empty? %>
4 <p class="field_hint">The Trash is empty.</p>
5<% else %>
6 <%= will_paginate @nodes %>
7 <table id="trashed_node_list">
8 <tr>
9 <th>Title</th>
10 <th>Was at</th>
11 <th>Trashed</th>
12 <th>Actions</th>
13 </tr>
14 <% @nodes.each do |node| %>
15 <% entry = node.last_trash_entry %>
16 <tr>
17 <% doomed_below = node.descendants.count %>
18 <td>
19 <%= link_to (node.draft&.title || node.slug), node_path(node) %><%= " (+ #{doomed_below} beneath)" if doomed_below > 0 %>
20 </td>
21 <td><%= entry&.metadata&.dig("path", "from") %></td>
22 <td>
23 <% if entry %>
24 <%= entry.occurred_at.strftime("%Y-%m-%d %H:%M") %> by <%= entry.actor_name %>
25 <% end %>
26 </td>
27 <td>
28 <%= button_to node_path(node), method: :delete,
29 form: { data: { confirm: doomed_below > 0 ?
30 "Delete \"#{node.draft&.title || node.slug}\" and the #{doomed_below} pages beneath it permanently? This cannot be undone." :
31 "Delete \"#{node.draft&.title || node.slug}\" permanently? This cannot be undone." },
32 class: 'button_to destructive' } do %>
33 <%= icon("trash-x", library: "tabler", "aria-hidden": true) %>
34 Delete permanently
35 <% end %>
36 </td>
37 </tr>
38 <% end %>
39 </table>
40 <%= will_paginate @nodes %>
41 <p class="field_hint">To restore a page, open it and pick a new parent in its Trash section.</p>
42<% end %>