summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-12 23:42:43 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-12 23:42:43 +0200
commitb271648f89cba7cafafa1b73b1658b1c1bc0e4b0 (patch)
tree567302b1aa6add08b46e0be028a0972f76c52abc
parentcf93acb8ad44ba9cd486e8f6457d9fd9fbc041cc (diff)
Rebuild the admin dashboard: icon nav, search, signposts, widgets
Replaces the old admin#index wizard -- accreted over years, never designed as a whole -- with the dashboard settled on this session: a three-icon nav (dashboard/search/log out, no locale selector), a nodes-first search bar, four task signposts, and two symmetric widgets (drafts/autosaves, recent changes) with a quiet housekeeping row beneath them. Node.recently_changed now filters and orders by the head page's own updated_at instead of the node's blanket timestamp, so a lock/unlock cycle with no actual publish no longer surfaces here, and the original publisher is no longer misattributed to someone else's housekeeping action. This also restores the "published" qualifier on each entry, which the query previously couldn't guarantee was true. @mynodes and its dedicated "My Work" table are retired along with the old wizard -- "Continue my work" is a link to the existing, already- correct NodesController#mine instead of a second, duplicate query. Its one dedicated test (dedup across multiple revisions by the same user) is ported to nodes_controller_test.rb, since mine already carries the same .distinct protection the old query did; it just had no test of its own until now.
-rw-r--r--app/controllers/admin_controller.rb21
-rw-r--r--app/helpers/nodes_helper.rb12
-rw-r--r--app/models/node.rb6
-rw-r--r--app/views/admin/_menu.html.erb14
-rw-r--r--app/views/admin/index.html.erb210
-rw-r--r--test/controllers/admin_controller_test.rb20
-rw-r--r--test/controllers/nodes_controller_test.rb20
-rw-r--r--test/models/node_test.rb24
8 files changed, 134 insertions, 193 deletions
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
index 8445997..37fd78b 100644
--- a/app/controllers/admin_controller.rb
+++ b/app/controllers/admin_controller.rb
@@ -5,25 +5,8 @@ class AdminController < ApplicationController
5 before_action :login_required 5 before_action :login_required
6 6
7 def index 7 def index
8 @drafts = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") 8 @drafts = Node.drafts_and_autosaves(current_user_id: current_user.id).limit(5)
9 .limit(50).order("updated_at desc") 9 @recent_changes = Node.recently_changed.limit(5)
10
11 @drafts_count = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL").count
12
13 @recent_changes = Node.where(
14 "updated_at < ? AND updated_at > ? AND parent_id IS NOT NULL",
15 Time.now, Time.now - 14.days
16 ).limit(50).order("updated_at desc")
17
18 ordered_with_level = Node.root.self_and_descendants_ordered_with_level
19 @sitemap_depth = {}
20 ordered_with_level.each { |node, level| @sitemap_depth[node.id] = level }
21 @sitemap = ordered_with_level.map(&:first).reject(&:update?)
22
23 @mynodes = Node.joins(:pages)
24 .where("pages.user_id = ? or pages.editor_id = ?", current_user, current_user)
25 .order("updated_at desc")
26 .distinct.first(50)
27 end 10 end
28 11
29 def conventions 12 def conventions
diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb
index 1268b63..5884c8c 100644
--- a/app/helpers/nodes_helper.rb
+++ b/app/helpers/nodes_helper.rb
@@ -28,6 +28,18 @@ module NodesHelper
28 User.all.map {|u| [u.login, u.id]} 28 User.all.map {|u| [u.login, u.id]}
29 end 29 end
30 30
31 def node_last_editor(node)
32 editor = node.draft&.editor || node.head&.editor
33 return nil unless editor
34 editor == current_user ? t("editor_self") : editor.login
35 end
36
37 def node_head_editor(node)
38 editor = node.head&.editor
39 return nil unless editor
40 editor == current_user ? t("publisher_self") : editor.login
41 end
42
31 DEFAULT_EVENT_TAG_BY_PAGE_TAG = { 43 DEFAULT_EVENT_TAG_BY_PAGE_TAG = {
32 'erfa-detail' => 'open-day', 44 'erfa-detail' => 'open-day',
33 'chaostreff-detail' => 'open-day' 45 'chaostreff-detail' => 'open-day'
diff --git a/app/models/node.rb b/app/models/node.rb
index aa2f7f3..1d0a089 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -377,10 +377,10 @@ class Node < ApplicationRecord
377 end 377 end
378 378
379 def self.recently_changed 379 def self.recently_changed
380 where( 380 includes(:head).where(
381 "nodes.updated_at < ? AND nodes.updated_at > ? AND nodes.parent_id IS NOT NULL", 381 "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL",
382 Time.now, Time.now - 14.days 382 Time.now, Time.now - 14.days
383 ) 383 ).order("pages.updated_at desc").references(:head)
384 end 384 end
385 385
386 protected 386 protected
diff --git a/app/views/admin/_menu.html.erb b/app/views/admin/_menu.html.erb
index 4302987..970429d 100644
--- a/app/views/admin/_menu.html.erb
+++ b/app/views/admin/_menu.html.erb
@@ -1,9 +1,5 @@
1<%= language_selector %> 1<%= link_to icon("home", library: "tabler", "aria-hidden": true), admin_path, "aria-label": "Dashboard" %>
2<%= button_to 'Logout', logout_path, method: :delete %> 2<a href="#" onclick="admin_search.display_toggle(); return false;" aria-label="Search"><%= icon("search", library: "tabler", "aria-hidden": true) %></a>
3<%= link_to 'Overview', admin_path %> 3<%= button_to logout_path, method: :delete, aria: { label: "Log out" } do %>
4<a href="#" onclick="admin_search.display_toggle(); return false;">search</a> 4 <%= icon("logout", library: "tabler", "aria-hidden": true) %>
5<%= link_to 'Nodes', nodes_path, selected?('nodes') %> 5<% end %>
6<%= link_to 'Assets', assets_path, selected?('assets') %>
7<%= link_to 'Events', events_path, selected?('events') %>
8<%= link_to 'User', users_path, selected?('users') %>
9<%= link_to 'Navigation', menu_items_path, selected?('menu_items') %>
diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb
index c67ccb3..bdb555e 100644
--- a/app/views/admin/index.html.erb
+++ b/app/views/admin/index.html.erb
@@ -1,155 +1,81 @@
1<div id="admin_wizard"> 1<h1>cccms dashboard</h1>
2 <h2>Quick links</h2> 2<div id="dashboard_search">
3 <div class="admin_wizard_button"><%= link_to 'Create post or page', new_node_path, :only_path => false %></div> 3 <%= form_tag admin_search_path, method: :get do %>
4 <div class="admin_wizard_button"><a href="#" id="admin_wizard_my_work" class="button">Continue my work</a></div> 4 <%= text_field_tag :search_term, nil, id: "dashboard_search_term", placeholder: "Search nodes and tags…", autocomplete: "off" %>
5 <div class="admin_wizard_button"><a href="#" id="admin_wizard_create_page" class="button">Add a page in the page tree</a></div> 5 <% end %>
6 <div class="admin_wizard_button"><%= link_to("Upload a new asset", new_asset_path, :only_path => false) %></div> 6 <div id="dashboard_search_results" class="search_results"></div>
7</div> 7</div>
8 8
9<p id="overview_toggle"> 9<div id="dashboard_signposts" class="button_row">
10 <a href="#" id="recent_changes_toggle" class="button">recent changes</a> 10 <%= link_to new_node_path, class: "action_button" do %>
11 <a href="#" id="my_work_toggle" class="button">my work</a> 11 <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create post
12 <a href="#" id="current_drafts_toggle" class="button">current drafts (<%= @drafts_count %>)</a> 12 <% end %>
13 <a href="#" id="admin_sitemap_toggle" class="button">site map</a> 13 <%= link_to chapters_nodes_path, class: "action_button" do %>
14</p> 14 <%= icon("map-pin", library: "tabler", "aria-hidden": true) %> Find a chapter
15 <% end %>
16 <%= link_to new_asset_path, class: "action_button" do %>
17 <%= icon("upload", library: "tabler", "aria-hidden": true) %> Upload asset
18 <% end %>
19 <%= link_to sitemap_nodes_path, class: "action_button" do %>
20 <%= icon("list-tree", library: "tabler", "aria-hidden": true) %> Add a page in the page tree
21 <% end %>
22</div>
15 23
16<div id="recent_changes_table"> 24<div id="dashboard_widgets">
17 25 <div class="dashboard_widget">
18 <h2>Recent Changes</h2> 26 <h3>Drafts and autosaves</h3>
27 <ul>
28 <% @drafts.each do |node| %>
29 <li>
30 <div>
31 <%= link_to title_for_node(node), node_path(node) %>
32 <span class="field_hint"><%= link_to_path("#{node.unique_name} ↗", node.unique_name) %></span>
33 </div>
34 <span class="dashboard_widget_meta">
35 <% if (editor = node_last_editor(node)) %><%= editor %>, <% end %><%= relative_time_phrase(node.updated_at) %>
36 </span>
37 </li>
38 <% end %>
39 </ul>
40 <%= link_to "See all drafts →", drafts_nodes_path %>
41 </div>
19 42
20 <table class="node_table"> 43 <div class="dashboard_widget">
21 <tr class="header"> 44 <h3>Recent changes</h3>
22 <th class="node_id">ID</th> 45 <ul>
23 <th class="title">Title</th>
24 <th class="actions">Actions</th>
25 <th class="editor">Locked by</th>
26 <th class="revision">Rev.</th>
27 </tr>
28 <% @recent_changes.each do |node| %> 46 <% @recent_changes.each do |node| %>
29 <tr class="<%= cycle("even", "odd") %>"> 47 <li>
30 <td class="node_id"><%= node.id %></td> 48 <div>
31 <td class="title"> 49 <%= link_to title_for_node(node), node_path(node) %>
32 <h4><%= link_to title_for_node(node), node_path(node) %></h4> 50 <span class="field_hint"><%= link_to_path("#{node.unique_name} ↗", node.unique_name) %></span>
33 <p><%= link_to_path(node.unique_name, node.unique_name) %></p> 51 </div>
34 </td> 52 <span class="dashboard_widget_meta">
35 <td class="actions"> 53 <%= (editor = node_head_editor(node)) ? t("published_by", editor: editor) : t("published") %>, <%= relative_time_phrase(node.head.updated_at) %>
36 <%= link_to 'show', node_path(node) %> 54 </span>
37 <%= link_to 'Revisions', node_revisions_path(node) %> 55 </li>
38 </td>
39 <td>
40 <%= node.lock_owner.login if node.lock_owner %>
41 </td>
42 <td>
43 <%= link_to ( node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY" ) ), node_revisions_path(node) %>
44 </td>
45 </tr>
46 <% end %> 56 <% end %>
47 </table> 57 </ul>
58 <%= link_to "See all recent changes →", recent_nodes_path %>
59 </div>
48</div> 60</div>
49 61
50<div id="my_work_table"> 62<div id="dashboard_housekeeping">
51 63 <h3>Housekeeping</h3>
52 <h2>My Work</h2> 64 <div class="button_row">
53 65 <%= link_to nodes_path, class: "action_button" do %>
54 <table class="node_table"> 66 <%= icon("list", library: "tabler", "aria-hidden": true) %> Nodes
55 <tr class="header">
56 <th class="node_id">ID</th>
57 <th class="title">Title</th>
58 <th class="actions">Actions</th>
59 <th class="editor">Locked by</th>
60 <th class="revision">Rev.</th>
61 </tr>
62 <% @mynodes.each do |node| %>
63 <tr class="<%= cycle("even", "odd") %>">
64 <td class="node_id"><%= node.id %></td>
65 <td class="title">
66 <h4><%= link_to title_for_node(node), node_path(node) %></h4>
67 <p><%= link_to_path(node.unique_name, node.unique_name) %></p>
68 </td>
69 <td class="actions">
70 <%= link_to 'show', node_path(node) %>
71 <%= link_to 'Revisions', node_revisions_path(node) %>
72 </td>
73 <td>
74 <%= node.lock_owner.login if node.lock_owner %>
75 </td>
76 <td>
77 <%= link_to ( node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY" ) ), node_revisions_path(node) %>
78 </td>
79 </tr>
80 <% end %> 67 <% end %>
81 </table> 68 <%= link_to events_path, class: "action_button" do %>
82</div> 69 <%= icon("calendar", library: "tabler", "aria-hidden": true) %> Events
83
84<div id="current_drafts_table">
85
86 <h2>Current Drafts & Autosaves (<%= @drafts_count %>)</h2>
87
88 <table class="node_table">
89 <tr class="header">
90 <th class="node_id">ID</th>
91 <th class="title">Title</th>
92 <th class="actions">Actions</th>
93 <th class="editor">Locked by</th>
94 <th class="revision">Rev.</th>
95 <th class="autosave">Autosave</th>
96 </tr>
97 <% @drafts.each do |node| %>
98 <tr class="<%= cycle("even", "odd") %>">
99 <td class="node_id"><%= node.id %></td>
100 <td class="title">
101 <h4><%= link_to title_for_node(node), node_path(node) %></h4>
102 <p><%= link_to_path(node.unique_name, node.unique_name) %></p>
103 </td>
104 <td class="actions">
105 <%= link_to 'show', node_path(node) %>
106 <%= link_to 'Revisions', node_revisions_path(node) %>
107 <% if pair = node.available_layer_pairs.last %>
108 <%= link_to 'diff', diff_node_revisions_path(node, start_revision: pair.first, end_revision: pair.last) %>
109 <% end %>
110 </td>
111 <td>
112 <%= node.lock_owner.login if node.lock_owner %>
113 </td>
114 <td>
115 <%= link_to ( node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY" ) ), node_revisions_path(node) %>
116 </td>
117 <td class="autosave">
118 <%= node.autosave ? "unsaved changes" : "" %>
119 </td>
120 </tr>
121 <% end %> 70 <% end %>
122 </table> 71 <%= link_to assets_path, class: "action_button" do %>
123</div> 72 <%= icon("folder", library: "tabler", "aria-hidden": true) %> Assets
124 73 <% end %>
125<div id="admin_sitemap_table"> 74 <%= link_to users_path, class: "action_button" do %>
126 75 <%= icon("users", library: "tabler", "aria-hidden": true) %> Users
127 <h2>Sitemap</h2>
128
129 <table class="node_table">
130 <tr class="header">
131 <th class="node_id">ID</th>
132 <th class="title">Title</th>
133 <th class="actions">Actions</th>
134 <th class="editor">Locked by</th>
135 </tr>
136 <% @sitemap.each do |node| %>
137 <% if !node.nil? %>
138 <tr class="<%= cycle("even", "odd") %>">
139 <td class="node_id" style="padding-left: <%= @sitemap_depth[node.id] * 30 %>px;"><%= node.id %></td>
140 <td class="title" style="padding-left: <%= @sitemap_depth[node.id] * 30 %>px;">
141 <h4><%= link_to title_for_node(node), node_path(node) %></h4>
142 <p><%= link_to_path(node.unique_name, node.unique_name) %></p>
143 </td>
144 <td class="actions">
145 <%= link_to 'create subpage', new_node_path(:parent_id => node.id) %>
146 <%= link_to 'Revisions', node_revisions_path(node) %>
147 </td>
148 <td>
149 <%= node.lock_owner.login if node.lock_owner %>
150 </td>
151 </tr>
152 <% end %> 76 <% end %>
77 <%= link_to menu_items_path, class: "action_button" do %>
78 <%= icon("menu-2", library: "tabler", "aria-hidden": true) %> Navigation
153 <% end %> 79 <% end %>
154 </table> 80 </div>
155</div> 81</div>
diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb
index 9beaf58..00a51e2 100644
--- a/test/controllers/admin_controller_test.rb
+++ b/test/controllers/admin_controller_test.rb
@@ -15,26 +15,6 @@ class AdminControllerTest < ActionController::TestCase
15 assert_includes assigns(:drafts), node 15 assert_includes assigns(:drafts), node
16 end 16 end
17 17
18 test "my work list shows each matching node only once, even with several revisions by the same user" do
19 login_as :quentin
20 user = User.find_by_login("quentin")
21 node = Node.root.children.create!(:slug => "dedup_test")
22 node.lock_for_editing!(user)
23 node.autosave!({:title => "v1"}, user)
24 node.save_draft!(user)
25 node.publish_draft!
26 node.lock_for_editing!(user)
27 node.autosave!({:title => "v2"}, user)
28 node.save_draft!(user)
29 node.publish_draft!
30 # three pages now exist on this node, all touched by quentin --
31 # without DISTINCT, the join would return this node three times
32
33 get :index
34 matches = assigns(:mynodes).select { |n| n.id == node.id }
35 assert_equal 1, matches.length
36 end
37
38 test "dashboard_search returns matching tags and nodes grouped separately" do 18 test "dashboard_search returns matching tags and nodes grouped separately" do
39 node = Node.root.children.create!(:slug => "dashboard_search_test") 19 node = Node.root.children.create!(:slug => "dashboard_search_test")
40 node.find_or_create_draft(User.find_by_login("aaron")) 20 node.find_or_create_draft(User.find_by_login("aaron"))
diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb
index b43d2de..81e3f45 100644
--- a/test/controllers/nodes_controller_test.rb
+++ b/test/controllers/nodes_controller_test.rb
@@ -521,6 +521,26 @@ class NodesControllerTest < ActionController::TestCase
521 assert_response :success 521 assert_response :success
522 end 522 end
523 523
524 test "mine shows each matching node only once, even with several revisions by the same user" do
525 login_as :quentin
526 user = User.find_by_login("quentin")
527 node = Node.root.children.create!(:slug => "dedup_test")
528 node.lock_for_editing!(user)
529 node.autosave!({:title => "v1"}, user)
530 node.save_draft!(user)
531 node.publish_draft!
532 node.lock_for_editing!(user)
533 node.autosave!({:title => "v2"}, user)
534 node.save_draft!(user)
535 node.publish_draft!
536 # three pages now exist on this node, all touched by quentin --
537 # without DISTINCT, the join would return this node three times
538
539 get :mine
540 matches = assigns(:nodes).select { |n| n.id == node.id }
541 assert_equal 1, matches.length
542 end
543
524 test "chapters combined with a search term does not raise an ambiguous column error" do 544 test "chapters combined with a search term does not raise an ambiguous column error" do
525 login_as :quentin 545 login_as :quentin
526 get :chapters, params: { :q => "Zombies" } 546 get :chapters, params: { :q => "Zombies" }
diff --git a/test/models/node_test.rb b/test/models/node_test.rb
index de540f8..0bce222 100644
--- a/test/models/node_test.rb
+++ b/test/models/node_test.rb
@@ -561,4 +561,28 @@ class NodeTest < ActiveSupport::TestCase
561 result = Node.drafts_and_autosaves(current_user_id: @user1.id).to_a 561 result = Node.drafts_and_autosaves(current_user_id: @user1.id).to_a
562 assert result.index(mine) < result.index(someone_elses_newer) 562 assert result.index(mine) < result.index(someone_elses_newer)
563 end 563 end
564
565 test "recently_changed includes a node whose head was recently published" do
566 node = Node.root.children.create!(:slug => "recent_changed_published")
567 node.find_or_create_draft(@user1)
568 node.autosave!({:title => "v1"}, @user1)
569 node.save_draft!(@user1)
570 node.publish_draft!
571
572 assert_includes Node.recently_changed, node
573 end
574
575 test "recently_changed excludes a node only touched by locking or unlocking after an old publish" do
576 node = Node.root.children.create!(:slug => "recent_changed_lock_only")
577 node.find_or_create_draft(@user1)
578 node.autosave!({:title => "v1"}, @user1)
579 node.save_draft!(@user1)
580 node.publish_draft!
581 node.head.update_column(:updated_at, 20.days.ago)
582
583 node.lock_for_editing!(@user1)
584 node.unlock!
585
586 assert_not_includes Node.recently_changed, node
587 end
564end 588end