summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-16 13:50:15 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-16 13:50:15 +0200
commit603ccfbd5fe96f0b83aaf3d5118aded2a39992fb (patch)
tree0e88ec41af6ce1a03e65bedb3f68a5a9881d96bb
parentbb7ef80d084f474bb4c3be0ae0d033aff3de0272 (diff)
Condense the action log into a scannable table
Several minor improvements to the action log presentation: * Now a table with date and human readable presentation as rows is displayed * If no changes in a title were detected, the old version is omitted * The "inferred" flag is demoted to the end of the line * You can zoom in on the node's history directly from a log line * byline for the first publish action is preserved * Revisions are directly linked to when a new one i published
-rw-r--r--app/helpers/node_actions_helper.rb74
-rw-r--r--app/models/node_action.rb25
-rw-r--r--app/views/node_actions/_change_details.html.erb30
-rw-r--r--app/views/node_actions/index.html.erb25
-rw-r--r--config/locales/de.yml13
-rw-r--r--config/locales/en.yml44
-rw-r--r--public/stylesheets/admin.css34
-rw-r--r--test/models/helpers/node_actions_helper_test.rb32
-rw-r--r--test/models/node_action_test.rb16
9 files changed, 222 insertions, 71 deletions
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb
index 55ca982..996f98d 100644
--- a/app/helpers/node_actions_helper.rb
+++ b/app/helpers/node_actions_helper.rb
@@ -16,8 +16,57 @@ module NodeActionsHelper
16 :action => h(action.action), :subject => subject_ref(action)).html_safe 16 :action => h(action.action), :subject => subject_ref(action)).html_safe
17 end 17 end
18 18
19
20 def action_details? action
21 m = action.metadata
22 return true if m["translation_diff"].present?
23 return true if m["title"].is_a?(Hash) && m.dig("title", "from") != m.dig("title", "to")
24 %w[author tags template_changed assets_changed
25 abstract_changed body_changed].any? { |key| m[key].present? }
26 end
27
28 # Plain strings by design -- safe_join in the template escapes them.
29 def default_locale_changes action
30 m = action.metadata
31 items = []
32 if m["title"].is_a?(Hash) && m.dig("title", "from") && m.dig("title", "from") != m.dig("title", "to")
33 items << t("node_actions.detail_title",
34 :from => m.dig("title", "from"), :to => m.dig("title", "to"))
35 end
36 items << t("node_actions.detail_author",
37 :from => m.dig("author", "from"), :to => m.dig("author", "to")) if m["author"]
38 items << t("node_actions.detail_tags",
39 :from => Array(m.dig("tags", "from")).join(", "),
40 :to => Array(m.dig("tags", "to")).join(", ")) if m["tags"]
41 items << t("node_actions.abstract_changed") if m["abstract_changed"]
42 items << t("node_actions.body_changed") if m["body_changed"]
43 items << t("node_actions.template_changed") if m["template_changed"]
44 items << t("node_actions.assets_changed") if m["assets_changed"]
45 items
46 end
47
48 def translation_changes diff
49 case diff["status"]
50 when "added" then [t("node_actions.locale_added", :title => diff.dig("title", "to"))]
51 when "removed" then [t("node_actions.locale_removed", :title => diff.dig("title", "from"))]
52 else
53 items = []
54 items << t("node_actions.detail_title",
55 :from => diff.dig("title", "from"), :to => diff.dig("title", "to")) if diff["title"]
56 items << t("node_actions.abstract_changed") if diff["abstract_changed"]
57 items << t("node_actions.body_changed") if diff["body_changed"]
58 items
59 end
60 end
61
19 private 62 private
20 63
64 def revision_ref action, key
65 label = t(key)
66 return label unless action.node && action.page
67 link_to(label, node_revision_path(action.node, action.page))
68 end
69
21 def actor_ref action 70 def actor_ref action
22 action.user ? link_to(h(action.actor_name), admin_log_path(:user_id => action.user_id)) 71 action.user ? link_to(h(action.actor_name), admin_log_path(:user_id => action.user_id))
23 : h(action.actor_name) 72 : h(action.actor_name)
@@ -29,17 +78,20 @@ module NodeActionsHelper
29 end 78 end
30 79
31 def summarize_publish action 80 def summarize_publish action
32 key = if action.metadata["via"] == "revision" 81 if action.metadata["via"] == "revision"
33 "node_actions.publish_rollback" 82 t("node_actions.publish_rollback",
34 elsif action.metadata.dig("title", "from").nil? 83 :actor => actor_ref(action), :subject => subject_ref(action),
35 "node_actions.publish_first" 84 :revision => revision_ref(action, "node_actions.revision_earlier")).html_safe
36 else 85 elsif action.metadata.dig("title", "from").nil?
37 "node_actions.publish" 86 author = action.metadata.dig("author", "to")
38 end 87 key = author ? "node_actions.publish_first_with_author" : "node_actions.publish_first"
39 88 t(key, :actor => actor_ref(action), :subject => subject_ref(action),
40 t(key, :actor => actor_ref(action), :subject => subject_ref(action), 89 :author => h(author)).html_safe
41 :from => h(action.metadata.dig("title", "from")), 90 else
42 :to => h(action.metadata.dig("title", "to"))).html_safe 91 t("node_actions.publish",
92 :actor => actor_ref(action), :subject => subject_ref(action),
93 :revision => revision_ref(action, "node_actions.revision_new")).html_safe
94 end
43 end 95 end
44 96
45 def summarize_move action 97 def summarize_move action
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
index 39935c7..792ae1e 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -82,21 +82,23 @@ class NodeAction < ApplicationRecord
82 # user agent, ever. Success only -- rejected attempts are not 82 # user agent, ever. Success only -- rejected attempts are not
83 # logged. 83 # logged.
84 84
85 def self.record!(node:, action:, user: nil, page: nil, locale: nil, **extra) 85 def self.record!(node:, action:, user: nil, page: nil, locale: nil,
86 occurred_at: nil, inferred_from: nil, **extra)
86 create!( 87 create!(
87 :node => node, 88 :node => node,
88 :page => page, 89 :page => page,
89 :user => user, 90 :user => user,
90 :action => action, 91 :action => action,
91 :locale => locale, 92 :locale => locale,
92 :occurred_at => Time.now, 93 :occurred_at => occurred_at || Time.now,
93 :metadata => { 94 :inferred_from => inferred_from,
95 :metadata => {
94 "username" => user&.login, 96 "username" => user&.login,
95 "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) { 97 "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) {
96 node&.head&.title || node&.draft&.title 98 node&.head&.title || node&.draft&.title
97 }, 99 },
98 }.merge(extra.stringify_keys) 100 }.merge(extra.stringify_keys)
99 ) 101 )
100 end 102 end
101 103
102 # Computes the publish-entry diff between an outgoing head and the 104 # Computes the publish-entry diff between an outgoing head and the
@@ -113,7 +115,10 @@ class NodeAction < ApplicationRecord
113 title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title } 115 title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title }
114 diff = { :title => { "from" => title_of.call(old_page), 116 diff = { :title => { "from" => title_of.call(old_page),
115 "to" => title_of.call(new_page) } } 117 "to" => title_of.call(new_page) } }
116 return diff unless old_page 118 unless old_page
119 diff[:author] = { "from" => nil, "to" => new_page.user&.login } if new_page.user
120 return diff
121 end
117 122
118 old_author, new_author = old_page.user&.login, new_page.user&.login 123 old_author, new_author = old_page.user&.login, new_page.user&.login
119 diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author 124 diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author
diff --git a/app/views/node_actions/_change_details.html.erb b/app/views/node_actions/_change_details.html.erb
index 383f53a..2583e8b 100644
--- a/app/views/node_actions/_change_details.html.erb
+++ b/app/views/node_actions/_change_details.html.erb
@@ -1,27 +1,27 @@
1<details class="node_action_details"> 1<details class="node_action_details">
2 <summary><%= t("node_actions.show_changes") %></summary> 2 <summary><%= t("node_actions.show_changes") %></summary>
3 <table> 3 <table>
4 <% action_entry.metadata["translation_diff"].each do |locale, diff| %> 4 <% if (default_items = default_locale_changes(action_entry)).any? %>
5 <tr>
6 <th><%= I18n.default_locale.to_s.upcase %></th>
7 <td>
8 <%= safe_join(default_items, ", ") %>
9 <% if action_entry.page && action_entry.node %>
10 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page) %>
11 <% end %>
12 </td>
13 </tr>
14 <% end %>
15 <% (action_entry.metadata["translation_diff"] || {}).each do |locale, diff| %>
5 <tr> 16 <tr>
6 <th><%= locale.upcase %></th> 17 <th><%= locale.upcase %></th>
7 <td> 18 <td>
8 <% case diff["status"] %> 19 <%= safe_join(translation_changes(diff), ", ") %>
9 <% when "added" %> 20 <% if action_entry.page && action_entry.node %>
10 <%= t("node_actions.locale_added", :title => diff.dig("title", "to")) %> 21 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page, :locale => locale) %>
11 <% when "removed" %>
12 <%= t("node_actions.locale_removed", :title => diff.dig("title", "from")) %>
13 <% else %>
14 <% if diff["title"] %>
15 <%= diff.dig("title", "from") %> → <%= diff.dig("title", "to") %>
16 <% end %>
17 <%= t("node_actions.abstract_changed") if diff["abstract_changed"] %>
18 <%= t("node_actions.body_changed") if diff["body_changed"] %>
19 <% end %> 22 <% end %>
20 </td> 23 </td>
21 </tr> 24 </tr>
22 <% end %> 25 <% end %>
23 </table> 26 </table>
24 <% if action_entry.page && action_entry.node %>
25 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page) %>
26 <% end %>
27</details> 27</details>
diff --git a/app/views/node_actions/index.html.erb b/app/views/node_actions/index.html.erb
index 92740cd..ee06213 100644
--- a/app/views/node_actions/index.html.erb
+++ b/app/views/node_actions/index.html.erb
@@ -6,17 +6,22 @@
6 6
7<%= will_paginate @actions %> 7<%= will_paginate @actions %>
8 8
9<ul id="node_action_list"> 9<table id="node_action_list">
10 <% @actions.each do |action| %> 10 <% @actions.each do |action| %>
11 <li class="node_action node_action--<%= action.action %>"> 11 <tr class="node_action node_action--<%= action.action %>">
12 <span class="node_action_time"><%= l(action.occurred_at, :format => :ccc) %></span> 12 <td class="node_action_time"><%= action.occurred_at.strftime("%Y-%m-%d %H:%M") %></td>
13 <% if action.inferred_from %> 13 <td class="node_action_body">
14 <span class="node_action_inferred" title="<%= action.inferred_from %>"><%= t("node_actions.backfilled") %></span> 14 <%= action_summary(action) %>
15 <% end %> 15 <% if action.node_id && params[:node_id].blank? %>
16 <span class="node_action_summary"><%= action_summary(action) %></span> 16 <%= link_to t("node_actions.node_history"), admin_log_path(:node_id => action.node_id), :class => "node_action_zoom" %>
17 <%= render "change_details", :action_entry => action if action.metadata["translation_diff"].present? %> 17 <% end %>
18 </li> 18 <% if action.inferred_from %>
19 <span class="node_action_inferred" title="<%= action.inferred_from %>"><%= t("node_actions.backfilled") %></span>
20 <% end %>
21 <%= render "change_details", :action_entry => action if action_details?(action) %>
22 </td>
23 </tr>
19 <% end %> 24 <% end %>
20</ul> 25</table>
21 26
22<%= will_paginate @actions %> 27<%= will_paginate @actions %>
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 795e4d0..9de6aac 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -90,9 +90,10 @@ de:
90 show_changes: "Änderungen an Übersetzungen" 90 show_changes: "Änderungen an Übersetzungen"
91 view_revision: "Diese Revision ansehen" 91 view_revision: "Diese Revision ansehen"
92 unknown: "%{actor} hat %{action} auf %{subject} aus" 92 unknown: "%{actor} hat %{action} auf %{subject} aus"
93 publish: "%{actor} hat %{subject} veröffentlicht, (vorher: %{from})" 93 publish: "%{actor} hat %{revision} von %{subject} veröffentlicht"
94 publish_rollback: "%{actor} hat %{subject} auf %{revision} zurückgesetzt"
94 publish_first: "%{actor} hat %{subject} zum ersten Mal veröffentlicht" 95 publish_first: "%{actor} hat %{subject} zum ersten Mal veröffentlicht"
95 publish_rollback: "%{actor} hat %{subject} auf eine frühere Revision zu¼ckgesetzt (vorher: %{from})" 96 publish_first_with_author: "%{actor} hat %{subject} zum ersten Mal ve¶ffentlicht (Autor: %{author})"
96 create: "%{actor} hat %{subject} unter %{path} angelegt" 97 create: "%{actor} hat %{subject} unter %{path} angelegt"
97 move: "%{actor} hat %{subject} von %{from} nach %{to} verschoben" 98 move: "%{actor} hat %{subject} von %{from} nach %{to} verschoben"
98 discard_autosave: "%{actor} hat ungespeicherte Änderungen an %{subject} verschoben" 99 discard_autosave: "%{actor} hat ungespeicherte Änderungen an %{subject} verschoben"
@@ -101,3 +102,11 @@ de:
101 locale_removed: "Übersetzung entfernt, letzter Titel \"%{title}\"" 102 locale_removed: "Übersetzung entfernt, letzter Titel \"%{title}\""
102 abstract_changed: "Abstract geändert" 103 abstract_changed: "Abstract geändert"
103 body_changed: "Text geändert" 104 body_changed: "Text geändert"
105 revision_new: "eine neue Revision"
106 revision_earlier: "eine frühere Revision"
107 node_history: "Chronik"
108 detail_title: "Titel „%{from}“ → „%{to}“"
109 detail_author: "Autor %{from} → %{to}"
110 detail_tags: "Tags %{from} → %{to}"
111 template_changed: "Template geändert"
112 assets_changed: "Bildliste geändert"
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 8470778..f1856c1 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -36,21 +36,29 @@ en:
36 page_aria_label: "Page %{page}" 36 page_aria_label: "Page %{page}"
37 37
38 node_actions: 38 node_actions:
39 heading: "Action log" 39 heading: "Action log"
40 show_all: "Show all entries" 40 show_all: "Show all entries"
41 backfilled: "backfilled" 41 backfilled: "backfilled"
42 show_changes: "translation changes" 42 show_changes: "translation changes"
43 view_revision: "view this revision" 43 view_revision: "view this revision"
44 actor_self: "you" 44 unknown: "%{actor} did %{action} on %{subject}"
45 unknown: "%{actor} did %{action} on %{subject}" 45 publish: "%{actor} published %{revision} of %{subject}"
46 publish: "%{actor} published %{subject} (was: %{from})" 46 publish_rollback: "%{actor} rolled %{subject} back to %{revision}"
47 publish_first: "%{actor} published %{subject} for the first time" 47 publish_first: "%{actor} published %{subject} for the first time"
48 publish_rollback: "%{actor} rolled %{subject} back to an earlier revision (was: %{from})" 48 publish_first_with_author: "%{actor} published %{subject} for the first time (author: %{author})"
49 create: "%{actor} created %{subject} at %{path}" 49 create: "%{actor} created %{subject} at %{path}"
50 move: "%{actor} moved %{subject} from %{from} to %{to}" 50 move: "%{actor} moved %{subject} from %{from} to %{to}"
51 discard_autosave: "%{actor} discarded unsaved changes on %{subject}" 51 discard_autosave: "%{actor} discarded unsaved changes on %{subject}"
52 destroy_draft: "%{actor} discarded the draft of %{subject}" 52 destroy_draft: "%{actor} discarded the draft of %{subject}"
53 locale_added: "translation added, titled \"%{title}\"" 53 locale_added: "translation added, titled \"%{title}\""
54 locale_removed: "translation removed, last titled \"%{title}\"" 54 locale_removed: "translation removed, last titled \"%{title}\""
55 abstract_changed: "abstract changed" 55 abstract_changed: "abstract changed"
56 body_changed: "body changed" 56 body_changed: "body changed"
57 revision_new: "a new revision"
58 revision_earlier: "an earlier revision"
59 node_history: "node history"
60 detail_title: "title \"%{from}\" → \"%{to}\""
61 detail_author: "author %{from} → %{to}"
62 detail_tags: "tags %{from} → %{to}"
63 template_changed: "template changed"
64 assets_changed: "attached images changed"
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index 5f0b165..c1273c6 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -1494,3 +1494,37 @@ div#image_browser ul li {
1494 gap: 8px; 1494 gap: 8px;
1495 flex-wrap: wrap; 1495 flex-wrap: wrap;
1496} 1496}
1497
1498/* ============================================================
1499 Action log
1500 ============================================================ */
1501
1502#node_action_list td.node_action_time {
1503 white-space: nowrap;
1504 vertical-align: top;
1505 font-family: monospace;
1506 font-size: 0.8em;
1507 color: #777;
1508 padding-right: 1em;
1509}
1510
1511#node_action_list td.node_action_body {
1512 vertical-align: top;
1513}
1514
1515.node_action_inferred {
1516 opacity: 0.5;
1517 font-size: smaller;
1518 margin-left: 0.5em;
1519}
1520
1521.node_action_zoom {
1522 font-size: smaller;
1523 margin-left: 0.5em;
1524}
1525
1526.node_action_details summary {
1527 cursor: pointer;
1528 font-size: smaller;
1529 color: #777;
1530}
diff --git a/test/models/helpers/node_actions_helper_test.rb b/test/models/helpers/node_actions_helper_test.rb
index 93bbdf6..5244c19 100644
--- a/test/models/helpers/node_actions_helper_test.rb
+++ b/test/models/helpers/node_actions_helper_test.rb
@@ -21,12 +21,13 @@ class NodeActionsHelperTest < ActionView::TestCase
21 "human_readable_node_name" => "Subject" }.merge(metadata)) 21 "human_readable_node_name" => "Subject" }.merge(metadata))
22 end 22 end
23 23
24 test "publish renders the title pair" do 24 test "publish renders the revision sentence, not the titles" do
25 out = action_summary(entry("publish", 25 out = action_summary(entry("publish",
26 { "via" => "draft", "title" => { "from" => "Old", "to" => "New" } })) 26 { "via" => "draft", "title" => { "from" => "Old", "to" => "New" } }))
27 27
28 assert_includes out, "quentin" 28 assert_includes out, "quentin"
29 assert_includes out, "Old" 29 assert_includes out, "new revision"
30 assert_not_includes out, "Old"
30 end 31 end
31 32
32 test "first publish uses its own sentence" do 33 test "first publish uses its own sentence" do
@@ -68,10 +69,8 @@ class NodeActionsHelperTest < ActionView::TestCase
68 69
69 test "metadata values are escaped" do 70 test "metadata values are escaped" do
70 out = action_summary(entry("publish", 71 out = action_summary(entry("publish",
71 { "human_readable_node_name" => "<b>bold</b>", 72 { "human_readable_node_name" => "<b>bold</b>" }))
72 "title" => { "from" => "<script>alert(1)</script>", "to" => "x" } }))
73 73
74 assert_not_includes out, "<script>"
75 assert_not_includes out, "<b>" 74 assert_not_includes out, "<b>"
76 end 75 end
77 76
@@ -82,4 +81,27 @@ class NodeActionsHelperTest < ActionView::TestCase
82 81
83 assert_includes out, "<a " 82 assert_includes out, "<a "
84 end 83 end
84
85 test "details are guarded off when nothing but an unchanged title is present" do
86 unchanged = entry("publish", { "title" => { "from" => "Same", "to" => "Same" } })
87 changed = entry("publish", { "title" => { "from" => "Old", "to" => "New" } })
88
89 assert_not action_details?(unchanged)
90 assert action_details?(changed)
91 end
92
93 test "first publish with a byline names the author" do
94 out = action_summary(entry("publish",
95 { "title" => { "from" => nil, "to" => "New" },
96 "author" => { "from" => nil, "to" => "quentin" } }))
97
98 assert_includes out, "author"
99 end
100
101 test "create entries with their flat title render and are guarded off details" do
102 e = entry("create", { "title" => "Initial", "path" => "a/b" })
103
104 assert_includes action_summary(e), "a/b"
105 assert_not action_details?(e)
106 end
85end 107end
diff --git a/test/models/node_action_test.rb b/test/models/node_action_test.rb
index 24d8245..b177cca 100644
--- a/test/models/node_action_test.rb
+++ b/test/models/node_action_test.rb
@@ -18,6 +18,12 @@ class NodeActionTest < ActiveSupport::TestCase
18 assert_equal({ :title => { "from" => nil, "to" => "Erstausgabe" } }, diff) 18 assert_equal({ :title => { "from" => nil, "to" => "Erstausgabe" } }, diff)
19 end 19 end
20 20
21 test "first publish carries the byline when the page has one" do
22 diff = NodeAction.head_diff(nil, build_page(:user => users(:quentin)))
23
24 assert_equal({ "from" => nil, "to" => users(:quentin).login }, diff[:author])
25 end
26
21 test "title pair is always present, even when unchanged" do 27 test "title pair is always present, even when unchanged" do
22 diff = NodeAction.head_diff(build_page, build_page) 28 diff = NodeAction.head_diff(build_page, build_page)
23 29
@@ -109,4 +115,14 @@ class NodeActionTest < ActiveSupport::TestCase
109 115
110 assert_nil NodeAction.head_diff(old_page, new_page)[:translation_diff] 116 assert_nil NodeAction.head_diff(old_page, new_page)[:translation_diff]
111 end 117 end
118
119 test "record! passes provenance and historical timestamps through" do
120 long_ago = 2.years.ago
121 action = NodeAction.record!(:node => nil, :action => "publish",
122 :occurred_at => long_ago,
123 :inferred_from => "from_page_revision")
124
125 assert_equal "from_page_revision", action.inferred_from
126 assert_in_delta long_ago.to_f, action.occurred_at.to_f, 1.0
127 end
112end 128end