summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/admin_controller.rb1
-rw-r--r--app/controllers/node_actions_controller.rb14
-rw-r--r--app/controllers/nodes_controller.rb12
-rw-r--r--app/helpers/node_actions_helper.rb117
-rw-r--r--app/models/concerns/rrule_humanizer.rb49
-rw-r--r--app/models/node.rb7
-rw-r--r--app/models/node_action.rb25
-rw-r--r--app/views/admin/index.html.erb25
-rw-r--r--app/views/events/_rrule_builder.html.erb33
-rw-r--r--app/views/events/edit.html.erb28
-rw-r--r--app/views/events/new.html.erb5
-rw-r--r--app/views/node_actions/_change_details.html.erb27
-rw-r--r--app/views/node_actions/index.html.erb27
-rw-r--r--app/views/nodes/new.html.erb4
-rw-r--r--app/views/nodes/show.html.erb6
-rw-r--r--config/locales/de.yml28
-rw-r--r--config/locales/en.yml28
-rw-r--r--config/routes.rb1
-rw-r--r--lib/tasks/node_actions.rake61
-rw-r--r--public/javascripts/admin_interface.js46
-rw-r--r--public/javascripts/admin_search.js30
-rw-r--r--public/javascripts/related_assets.js9
-rw-r--r--public/stylesheets/admin.css47
-rw-r--r--test/controllers/node_actions_controller_test.rb51
-rw-r--r--test/models/concerns/rrule_humanizer_test.rb18
-rw-r--r--test/models/helpers/node_actions_helper_test.rb107
-rw-r--r--test/models/node_action_test.rb16
27 files changed, 734 insertions, 88 deletions
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
index 37fd78b..40cfbdc 100644
--- a/app/controllers/admin_controller.rb
+++ b/app/controllers/admin_controller.rb
@@ -7,6 +7,7 @@ class AdminController < ApplicationController
7 def index 7 def index
8 @drafts = Node.drafts_and_autosaves(current_user_id: current_user.id).limit(5) 8 @drafts = Node.drafts_and_autosaves(current_user_id: current_user.id).limit(5)
9 @recent_changes = Node.recently_changed.limit(5) 9 @recent_changes = Node.recently_changed.limit(5)
10 @actions = NodeAction.order(:occurred_at => :desc, :id => :desc).limit(5)
10 end 11 end
11 12
12 def conventions 13 def conventions
diff --git a/app/controllers/node_actions_controller.rb b/app/controllers/node_actions_controller.rb
new file mode 100644
index 0000000..6e46719
--- /dev/null
+++ b/app/controllers/node_actions_controller.rb
@@ -0,0 +1,14 @@
1class NodeActionsController < ApplicationController
2
3 before_action :login_required
4
5 layout 'admin'
6
7 def index
8 @actions = NodeAction.order(:occurred_at => :desc, :id => :desc)
9 @actions = @actions.where(:node_id => params[:node_id]) if params[:node_id].present?
10 @actions = @actions.where(:user_id => params[:user_id]) if params[:user_id].present?
11 @actions = @actions.includes(:node, :user)
12 .paginate(:page => params[:page], :per_page => 50)
13 end
14end
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index b66ea49..9834a17 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -27,12 +27,7 @@ class NodesController < ApplicationController
27 def new 27 def new
28 @node = Node.new node_params 28 @node = Node.new node_params
29 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" 29 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic"
30 if params.has_key?(:parent_id) 30 @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id)
31 @parent_id = params[:parent_id]
32 parent = Node.find(@parent_id)
33 @parent_name = parent.title
34 @parent_unique_name = parent.current_unique_name
35 end
36 end 31 end
37 32
38 def create 33 def create
@@ -57,10 +52,7 @@ class NodesController < ApplicationController
57 redirect_to(edit_node_path(@node)) 52 redirect_to(edit_node_path(@node))
58 else 53 else
59 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" 54 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic"
60 if params[:parent_id].present? 55 @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id)
61 @parent_id = params[:parent_id]
62 @parent_name = Node.find(@parent_id).title
63 end
64 render :new 56 render :new
65 end 57 end
66 end 58 end
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb
new file mode 100644
index 0000000..996f98d
--- /dev/null
+++ b/app/helpers/node_actions_helper.rb
@@ -0,0 +1,117 @@
1module NodeActionsHelper
2 include ERB::Util
3
4 # One sentence per entry, rendered from metadata alone, so entries
5 # stay renderable after the rows they reference are gone. Live
6 # associations only upgrade plain names to links. Every metadata
7 # value passes through h() here -- this helper is the escaping
8 # boundary; the locale sentences are trusted, the data never is.
9 def action_summary action
10 renderer = "summarize_#{action.action}"
11 return send(renderer, action) if respond_to?(renderer, true)
12
13 # Unknown-verb fallback: the log outlives the vocabulary. A renamed
14 # or future verb degrades to an ugly sentence, never to a 500.
15 t("node_actions.unknown", :actor => actor_ref(action),
16 :action => h(action.action), :subject => subject_ref(action)).html_safe
17 end
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
62 private
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
70 def actor_ref action
71 action.user ? link_to(h(action.actor_name), admin_log_path(:user_id => action.user_id))
72 : h(action.actor_name)
73 end
74
75 def subject_ref action
76 action.node ? link_to(h(action.subject_name), node_path(action.node))
77 : h(action.subject_name)
78 end
79
80 def summarize_publish action
81 if action.metadata["via"] == "revision"
82 t("node_actions.publish_rollback",
83 :actor => actor_ref(action), :subject => subject_ref(action),
84 :revision => revision_ref(action, "node_actions.revision_earlier")).html_safe
85 elsif action.metadata.dig("title", "from").nil?
86 author = action.metadata.dig("author", "to")
87 key = author ? "node_actions.publish_first_with_author" : "node_actions.publish_first"
88 t(key, :actor => actor_ref(action), :subject => subject_ref(action),
89 :author => h(author)).html_safe
90 else
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
95 end
96
97 def summarize_move action
98 t("node_actions.move", :actor => actor_ref(action), :subject => subject_ref(action),
99 :from => h(action.metadata.dig("path", "from")),
100 :to => h(action.metadata.dig("path", "to"))).html_safe
101 end
102
103 def summarize_create action
104 t("node_actions.create", :actor => actor_ref(action), :subject => subject_ref(action),
105 :path => h(action.metadata["path"])).html_safe
106 end
107
108 def summarize_discard_autosave action
109 t("node_actions.discard_autosave",
110 :actor => actor_ref(action), :subject => subject_ref(action)).html_safe
111 end
112
113 def summarize_destroy_draft action
114 t("node_actions.destroy_draft",
115 :actor => actor_ref(action), :subject => subject_ref(action)).html_safe
116 end
117end
diff --git a/app/models/concerns/rrule_humanizer.rb b/app/models/concerns/rrule_humanizer.rb
index 8231de8..07141c1 100644
--- a/app/models/concerns/rrule_humanizer.rb
+++ b/app/models/concerns/rrule_humanizer.rb
@@ -15,8 +15,8 @@ module RruleHumanizer
15 }.freeze 15 }.freeze
16 16
17 ORDINAL_NAMES = { 17 ORDINAL_NAMES = {
18 de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", -1=>"letzten", -2=>"vorletzten" }, 18 de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", 5=>"fünften", -1=>"letzten", -2=>"vorletzten" },
19 en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", -1=>"last", -2=>"second-to-last" } 19 en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", 5=>"fifth", -1=>"last", -2=>"second-to-last" }
20 }.freeze 20 }.freeze
21 21
22 MONTH_NAMES = { 22 MONTH_NAMES = {
@@ -35,7 +35,8 @@ module RruleHumanizer
35 ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en] 35 ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en]
36 months = MONTH_NAMES[loc] || MONTH_NAMES[:en] 36 months = MONTH_NAMES[loc] || MONTH_NAMES[:en]
37 37
38 days = byday&.split(",")&.map do |d| 38 byday_values = byday&.split(",")
39 days = byday_values&.map do |d|
39 if d =~ /^(-?\d+)([A-Z]{2})$/ 40 if d =~ /^(-?\d+)([A-Z]{2})$/
40 "#{ordinals[$1.to_i]} #{weekdays[$2]}" 41 "#{ordinals[$1.to_i]} #{weekdays[$2]}"
41 else 42 else
@@ -43,6 +44,32 @@ module RruleHumanizer
43 end 44 end
44 end 45 end
45 46
47 excluded_monthly_ordinal = nil
48 excluded_monthly_weekday = nil
49 selected_monthly_ordinals = nil
50 selected_monthly_weekday = nil
51 if freq == "MONTHLY" && byday_values.present?
52 ordinal_days = byday_values.map { |d| d.match(/^([1-5])([A-Z]{2})$/) }
53 if ordinal_days.all?
54 positions = ordinal_days.map { |match| match[1].to_i }.uniq.sort
55 weekdays_in_rule = ordinal_days.map { |match| match[2] }.uniq
56 if weekdays_in_rule.size == 1
57 selected_monthly_ordinals = positions
58 selected_monthly_weekday = weekdays_in_rule.first
59 missing_positions = (1..5).to_a - positions
60 if positions.size == 4 && missing_positions.size == 1
61 excluded_monthly_ordinal = missing_positions.first
62 excluded_monthly_weekday = selected_monthly_weekday
63 end
64 end
65 end
66 end
67
68 join_ordinals = lambda do |ordinal_values, conjunction|
69 names = ordinal_values.map { |ordinal| ordinals[ordinal] }
70 names.size > 1 ? "#{names[0..-2].join(', ')} #{conjunction} #{names.last}" : names.first
71 end
72
46 base = 73 base =
47 case loc 74 case loc
48 when :de 75 when :de
@@ -59,14 +86,26 @@ module RruleHumanizer
59 interval == 2 ? "Alle zwei Wochen" : "Wöchentlich" 86 interval == 2 ? "Alle zwei Wochen" : "Wöchentlich"
60 end 87 end
61 when "MONTHLY" 88 when "MONTHLY"
62 days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich" 89 if excluded_monthly_ordinal
90 "Jeden #{weekdays[excluded_monthly_weekday]} im Monat, außer dem #{ordinals[excluded_monthly_ordinal]} #{weekdays[excluded_monthly_weekday]}"
91 elsif selected_monthly_ordinals
92 "Jeden #{join_ordinals.call(selected_monthly_ordinals, 'und')} #{weekdays[selected_monthly_weekday]} im Monat"
93 else
94 days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich"
95 end
63 end 96 end
64 else 97 else
65 case freq 98 case freq
66 when "WEEKLY" 99 when "WEEKLY"
67 days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly") 100 days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly")
68 when "MONTHLY" 101 when "MONTHLY"
69 days ? "Every #{days.join(' and ')} of the month" : "Monthly" 102 if excluded_monthly_ordinal
103 "Every #{weekdays[excluded_monthly_weekday]} of the month, except the #{ordinals[excluded_monthly_ordinal]} #{weekdays[excluded_monthly_weekday]}"
104 elsif selected_monthly_ordinals
105 "Every #{join_ordinals.call(selected_monthly_ordinals, 'and')} #{weekdays[selected_monthly_weekday]} of the month"
106 else
107 days ? "Every #{days.join(' and ')} of the month" : "Monthly"
108 end
70 end 109 end
71 end 110 end
72 return nil unless base 111 return nil unless base
diff --git a/app/models/node.rb b/app/models/node.rb
index 6ca607d..717f5b4 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -268,9 +268,12 @@ class Node < ApplicationRecord
268 parent.nil? ? [slug] : parent.path_to_root.push(slug) 268 parent.nil? ? [slug] : parent.path_to_root.push(slug)
269 end 269 end
270 270
271 def computed_unique_name
272 path = path_to_root[1..-1].join("/") # excluding root
273 end
274
271 def current_unique_name 275 def current_unique_name
272 path = path_to_root[1..-1] # excluding root 276 self.unique_name = computed_unique_name
273 self.unique_name = path.join("/")
274 end 277 end
275 278
276 def update_unique_name 279 def update_unique_name
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/admin/index.html.erb b/app/views/admin/index.html.erb
index a9c0512..1b4a0a0 100644
--- a/app/views/admin/index.html.erb
+++ b/app/views/admin/index.html.erb
@@ -40,13 +40,36 @@
40 <%= link_to "See all drafts →", drafts_nodes_path %> 40 <%= link_to "See all drafts →", drafts_nodes_path %>
41 </div> 41 </div>
42 42
43 <div class="dashboard_widget"> 43 <!--div class="dashboard_widget">
44 <h3>Recent changes</h3> 44 <h3>Recent changes</h3>
45 <ul> 45 <ul>
46 <%= render partial: "nodes/recent_change_item", collection: @recent_changes, as: :node %> 46 <%= render partial: "nodes/recent_change_item", collection: @recent_changes, as: :node %>
47 </ul> 47 </ul>
48 <%= link_to "See all recent changes →", recent_nodes_path %> 48 <%= link_to "See all recent changes →", recent_nodes_path %>
49 </div-->
50
51 <div class="dashboard_widget">
52 <h3>Recent changes</h3>
53 <table id="node_action_list">
54 <% @actions.each do |action| %>
55 <tr class="node_action node_action--<%= action.action %>">
56 <td class="node_action_time"><%= raw(action.occurred_at.strftime("%Y-%m-%d %H:%M").gsub(" ", "<br/>")) %></td>
57 <td class="node_action_body">
58 <%= action_summary(action) %>
59 <% if action.node_id && params[:node_id].blank? %>
60 <%= link_to t("node_actions.node_history"), admin_log_path(:node_id => action.node_id), :class => "node_action_zoom" %>
61 <% end %>
62 <% if action.inferred_from %>
63 <span class="node_action_inferred" title="<%= action.inferred_from %>"><%= t("node_actions.backfilled") %></span>
64 <% end %>
65 <%= render "node_actions/change_details", :action_entry => action if action_details?(action) %>
66 </td>
67 </tr>
68 <% end %>
69 </table>
70 <%= link_to "See all recent changes →", admin_log_path %>
49 </div> 71 </div>
72
50</div> 73</div>
51 74
52<div id="dashboard_housekeeping"> 75<div id="dashboard_housekeeping">
diff --git a/app/views/events/_rrule_builder.html.erb b/app/views/events/_rrule_builder.html.erb
new file mode 100644
index 0000000..1ffec0a
--- /dev/null
+++ b/app/views/events/_rrule_builder.html.erb
@@ -0,0 +1,33 @@
1<div id="rrule_builder">
2 <p>
3 <label><%= radio_button_tag "rrule_freq", "weekly", true %> Weekly</label>
4 <label><%= radio_button_tag "rrule_freq", "monthly" %> Monthly</label>
5 </p>
6 <div id="rrule_weekly_options">
7 <p><label><%= check_box_tag "rrule_biweekly" %> Every 2 weeks</label></p>
8 <p>
9 <% %w[MO TU WE TH FR SA SU].each do |code| %>
10 <label><%= check_box_tag "rrule_byday_#{code}" %> <%= RruleHumanizer::WEEKDAY_NAMES_ABBR[:de][code] %></label>
11 <% end %>
12 </p>
13 </div>
14 <div id="rrule_monthly_options" style="display: none;">
15 <p><label><%= check_box_tag "rrule_monthly_ordinal" %> On a specific weekday each month</label></p>
16 <p id="rrule_ordinal_fields" style="display: none;">
17 <%= select_tag "rrule_ordinal", options_for_select([["1.", 1], ["2.", 2], ["3.", 3], ["4.", 4], ["letzter", -1], ["vorletzter", -2], ["Selected weeks", "custom"]]) %>
18 <%= select_tag "rrule_ordinal_day", options_for_select(%w[MO TU WE TH FR SA SU].map { |c| [RruleHumanizer::WEEKDAY_NAMES[:de][c], c] }) %>
19 </p>
20 <p id="rrule_custom_ordinal_fields" style="display: none;">
21 Weeks of the month:
22 <% (1..5).each do |ordinal| %>
23 <label><%= check_box_tag "rrule_custom_ordinal_#{ordinal}" %> <%= ordinal %>.</label>
24 <% end %>
25 </p>
26 </div>
27 <p>
28 <label><%= check_box_tag "rrule_exclude_month" %> Except in one month</label>
29 <%= select_tag "rrule_excluded_month", options_for_select((1..12).map { |m| [RruleHumanizer::MONTH_NAMES[:de][m-1], m] }), style: "display: none;" %>
30 </p>
31 <span class="field_hint">Builds the field below automatically. If your pattern is more complex than this covers, just edit it directly below - these controls won't touch it unless you use them.</span>
32</div>
33<%= f.text_field :rrule, id: "event_rrule" %>
diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb
index b6564a4..4532ff2 100644
--- a/app/views/events/edit.html.erb
+++ b/app/views/events/edit.html.erb
@@ -40,33 +40,7 @@
40 40
41 <div class="node_description">Recurrence</div> 41 <div class="node_description">Recurrence</div>
42 <div class="node_content"> 42 <div class="node_content">
43 <div id="rrule_builder"> 43 <%= render "rrule_builder", f: f %>
44 <p>
45 <label><%= radio_button_tag "rrule_freq", "weekly", true %> Weekly</label>
46 <label><%= radio_button_tag "rrule_freq", "monthly" %> Monthly</label>
47 </p>
48 <div id="rrule_weekly_options">
49 <p><label><%= check_box_tag "rrule_biweekly" %> Every 2 weeks</label></p>
50 <p>
51 <% %w[MO TU WE TH FR SA SU].each do |code| %>
52 <label><%= check_box_tag "rrule_byday_#{code}" %> <%= RruleHumanizer::WEEKDAY_NAMES_ABBR[:de][code] %></label>
53 <% end %>
54 </p>
55 </div>
56 <div id="rrule_monthly_options" style="display: none;">
57 <p><label><%= check_box_tag "rrule_monthly_ordinal" %> On a specific weekday each month</label></p>
58 <p id="rrule_ordinal_fields" style="display: none;">
59 <%= select_tag "rrule_ordinal", options_for_select([["1.", 1], ["2.", 2], ["3.", 3], ["4.", 4], ["letzter", -1], ["vorletzter", -2]]) %>
60 <%= select_tag "rrule_ordinal_day", options_for_select(%w[MO TU WE TH FR SA SU].map { |c| [RruleHumanizer::WEEKDAY_NAMES[:de][c], c] }) %>
61 </p>
62 </div>
63 <p>
64 <label><%= check_box_tag "rrule_exclude_month" %> Except in one month</label>
65 <%= select_tag "rrule_excluded_month", options_for_select((1..12).map { |m| [RruleHumanizer::MONTH_NAMES[:de][m-1], m] }), style: "display: none;" %>
66 </p>
67 <span class="field_hint">Builds the field below automatically. If your pattern is more complex than this covers, just edit it directly below - these controls won't touch it unless you use them.</span>
68 </div>
69 <%= f.text_field :rrule, id: "event_rrule" %>
70 </div> 44 </div>
71 45
72 <div class="node_description">Title</div> 46 <div class="node_description">Title</div>
diff --git a/app/views/events/new.html.erb b/app/views/events/new.html.erb
index 7a1ee7a..028fce7 100644
--- a/app/views/events/new.html.erb
+++ b/app/views/events/new.html.erb
@@ -21,8 +21,8 @@
21 <div class="node_description">End time</div> 21 <div class="node_description">End time</div>
22 <div class="node_content"><%= f.datetime_select :end_time %></div> 22 <div class="node_content"><%= f.datetime_select :end_time %></div>
23 23
24 <div class="node_description">Rrule</div> 24 <div class="node_description">Recurrence</div>
25 <div class="node_content"><%= f.text_field :rrule %></div> 25 <div class="node_content"><%= render "rrule_builder", f: f %></div>
26 26
27 <div class="node_description">Title</div> 27 <div class="node_description">Title</div>
28 <div class="node_content"> 28 <div class="node_content">
@@ -53,4 +53,3 @@
53 </div> 53 </div>
54 </div> 54 </div>
55<% end %> 55<% end %>
56
diff --git a/app/views/node_actions/_change_details.html.erb b/app/views/node_actions/_change_details.html.erb
new file mode 100644
index 0000000..2583e8b
--- /dev/null
+++ b/app/views/node_actions/_change_details.html.erb
@@ -0,0 +1,27 @@
1<details class="node_action_details">
2 <summary><%= t("node_actions.show_changes") %></summary>
3 <table>
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| %>
16 <tr>
17 <th><%= locale.upcase %></th>
18 <td>
19 <%= safe_join(translation_changes(diff), ", ") %>
20 <% if action_entry.page && action_entry.node %>
21 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page, :locale => locale) %>
22 <% end %>
23 </td>
24 </tr>
25 <% end %>
26 </table>
27</details>
diff --git a/app/views/node_actions/index.html.erb b/app/views/node_actions/index.html.erb
new file mode 100644
index 0000000..ee06213
--- /dev/null
+++ b/app/views/node_actions/index.html.erb
@@ -0,0 +1,27 @@
1<h1><%= t("node_actions.heading") %></h1>
2
3<% if params[:node_id].present? || params[:user_id].present? %>
4 <p><%= link_to t("node_actions.show_all"), admin_log_path %></p>
5<% end %>
6
7<%= will_paginate @actions %>
8
9<table id="node_action_list">
10 <% @actions.each do |action| %>
11 <tr class="node_action node_action--<%= action.action %>">
12 <td class="node_action_time"><%= action.occurred_at.strftime("%Y-%m-%d %H:%M") %></td>
13 <td class="node_action_body">
14 <%= action_summary(action) %>
15 <% if action.node_id && params[:node_id].blank? %>
16 <%= link_to t("node_actions.node_history"), admin_log_path(:node_id => action.node_id), :class => "node_action_zoom" %>
17 <% end %>
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>
24 <% end %>
25</table>
26
27<%= will_paginate @actions %>
diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb
index 49149d5..38eac84 100644
--- a/app/views/nodes/new.html.erb
+++ b/app/views/nodes/new.html.erb
@@ -32,8 +32,8 @@
32 <div id="parent_search_field" style="<%= @selected_kind == "generic" ? "" : "display: none;" %>"> 32 <div id="parent_search_field" style="<%= @selected_kind == "generic" ? "" : "display: none;" %>">
33 <div class="node_description">Parent</div> 33 <div class="node_description">Parent</div>
34 <div class="node_content"> 34 <div class="node_content">
35 <%= text_field_tag :parent_search_term, @parent_name %> 35 <%= text_field_tag :parent_search_term, @parent&.title %>
36 <%= hidden_field_tag :parent_id, @parent_id, data: { unique_name: @parent_unique_name } %> 36 <%= hidden_field_tag :parent_id, @parent&.id, data: { unique_name: @parent&.current_unique_name } %>
37 <div id="parent_search_results" class="search_results"></div> 37 <div id="parent_search_results" class="search_results"></div>
38 </div> 38 </div>
39 </div> 39 </div>
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb
index 66f04f9..ae25571 100644
--- a/app/views/nodes/show.html.erb
+++ b/app/views/nodes/show.html.erb
@@ -170,11 +170,11 @@
170 </div> 170 </div>
171 </div> 171 </div>
172 172
173 <div class="node_description">Revisions</div> 173 <div class="node_description">History</div>
174 <div class="node_content node_info_group"> 174 <div class="node_content node_info_group">
175 <details> 175 <details>
176 <summary> 176 <summary>
177 <%= pluralize(@node.pages.count, 'revision', 'revisions') %> 177 <%= pluralize(@node.pages.count, 'published revision', 'published revisions') %>
178 </summary> 178 </summary>
179 <ul> 179 <ul>
180 <% @node.pages.order(:revision).each do |page| %> 180 <% @node.pages.order(:revision).each do |page| %>
@@ -182,7 +182,7 @@
182 <% end %> 182 <% end %>
183 </ul> 183 </ul>
184 </details> 184 </details>
185 <p class="revisions_full_history_link"><%= link_to 'Full history (diff / restore)', node_revisions_path(@node) %></p> 185 <p class="revisions_full_history_link"><%= link_to 'Revision history (diff / restore)', node_revisions_path(@node) %> | <%= link_to t("node_actions.heading"), admin_log_path(:node_id => @node.id) %></p>
186 </div> 186 </div>
187 187
188 188
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 74cd183..9de6aac 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -82,3 +82,31 @@ de:
82 page_gap: "&hellip;" 82 page_gap: "&hellip;"
83 container_aria_label: "Seitennavigation" 83 container_aria_label: "Seitennavigation"
84 page_aria_label: "Seite %{page}" 84 page_aria_label: "Seite %{page}"
85
86 node_actions:
87 heading: "Letzte Änderungen"
88 show_all: "Alle Einträge zeigen"
89 backfilled: "rekonstruiert"
90 show_changes: "Änderungen an Übersetzungen"
91 view_revision: "Diese Revision ansehen"
92 unknown: "%{actor} hat %{action} auf %{subject} aus"
93 publish: "%{actor} hat %{revision} von %{subject} veröffentlicht"
94 publish_rollback: "%{actor} hat %{subject} auf %{revision} zurückgesetzt"
95 publish_first: "%{actor} hat %{subject} zum ersten Mal veröffentlicht"
96 publish_first_with_author: "%{actor} hat %{subject} zum ersten Mal veröffentlicht (Autor: %{author})"
97 create: "%{actor} hat %{subject} unter %{path} angelegt"
98 move: "%{actor} hat %{subject} von %{from} nach %{to} verschoben"
99 discard_autosave: "%{actor} hat ungespeicherte Änderungen an %{subject} verschoben"
100 destroy_draft: "%{actor} hat den Entwurf von %{subject} verworfen"
101 locale_added: "Übersetzung angelegt, Titel \"%{title}\""
102 locale_removed: "Übersetzung entfernt, letzter Titel \"%{title}\""
103 abstract_changed: "Abstract 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 c52280d..f1856c1 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -34,3 +34,31 @@ en:
34 page_gap: "&hellip;" 34 page_gap: "&hellip;"
35 container_aria_label: "Pagination" 35 container_aria_label: "Pagination"
36 page_aria_label: "Page %{page}" 36 page_aria_label: "Page %{page}"
37
38 node_actions:
39 heading: "Action log"
40 show_all: "Show all entries"
41 backfilled: "backfilled"
42 show_changes: "translation changes"
43 view_revision: "view this revision"
44 unknown: "%{actor} did %{action} on %{subject}"
45 publish: "%{actor} published %{revision} of %{subject}"
46 publish_rollback: "%{actor} rolled %{subject} back to %{revision}"
47 publish_first: "%{actor} published %{subject} for the first time"
48 publish_first_with_author: "%{actor} published %{subject} for the first time (author: %{author})"
49 create: "%{actor} created %{subject} at %{path}"
50 move: "%{actor} moved %{subject} from %{from} to %{to}"
51 discard_autosave: "%{actor} discarded unsaved changes on %{subject}"
52 destroy_draft: "%{actor} discarded the draft of %{subject}"
53 locale_added: "translation added, titled \"%{title}\""
54 locale_removed: "translation removed, last titled \"%{title}\""
55 abstract_changed: "abstract 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/config/routes.rb b/config/routes.rb
index 8e5ff23..c2b9590 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -86,6 +86,7 @@ Cccms::Application.routes.draw do
86 match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get 86 match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get
87 match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get 87 match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get
88 match 'dashboard_search' => 'admin#dashboard_search', :as => :admin_dashboard_search, :via => :get 88 match 'dashboard_search' => 'admin#dashboard_search', :as => :admin_dashboard_search, :via => :get
89 match 'log' => 'node_actions#index', :as => :admin_log, :via => :get
89 end 90 end
90 91
91 match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete 92 match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete
diff --git a/lib/tasks/node_actions.rake b/lib/tasks/node_actions.rake
new file mode 100644
index 0000000..fdd286c
--- /dev/null
+++ b/lib/tasks/node_actions.rake
@@ -0,0 +1,61 @@
1namespace :node_actions do
2 desc "Rebuild inferred NodeAction entries from existing revisions and " \
3 "node timestamps. Witnessed entries (inferred_from IS NULL) are " \
4 "never touched; previously inferred ones are deleted and " \
5 "regenerated, so the task is idempotent. Scope with NODE_ID=n."
6 task :backfill => :environment do
7 scope = Node.where.not(:parent_id => nil)
8 scope = scope.where(:id => ENV["NODE_ID"]) if ENV["NODE_ID"]
9
10 created = 0
11
12 ActiveRecord::Base.transaction do
13 stale = NodeAction.where.not(:inferred_from => nil)
14 stale = stale.where(:node_id => ENV["NODE_ID"]) if ENV["NODE_ID"]
15 puts "Removing #{stale.count} previously inferred entries"
16 stale.delete_all
17
18 witnessed_creates = NodeAction.where(:action => "create", :inferred_from => nil).pluck(:node_id).to_set
19 witnessed_promotes = NodeAction.where(:action => "publish", :inferred_from => nil).pluck(:page_id).to_set
20
21 scope.find_each do |node|
22 # Every page row except the current draft was once promoted to
23 # head. Autosaves never carry node_id, so they cannot appear.
24 revisions = node.pages.to_a.reject { |page| page.id == node.draft_id }
25 first_page = node.pages.first
26
27 unless witnessed_creates.include?(node.id)
28 NodeAction.record!(
29 :node => node, :page => first_page, :user => first_page&.editor,
30 :action => "create",
31 :occurred_at => node.created_at,
32 :inferred_from => "from_node_created_at",
33 :title => first_page&.translations&.find_by(:locale => I18n.default_locale)&.title,
34 :path => node.unique_name,
35 :human_readable_node_name =>
36 first_page&.translations&.find_by(:locale => I18n.default_locale)&.title)
37 created += 1
38 end
39
40 previous = nil
41 revisions.each do |page|
42 unless witnessed_promotes.include?(page.id)
43 diff = NodeAction.head_diff(previous, page)
44 NodeAction.record!(
45 :node => node, :page => page, :user => page.editor,
46 :action => "publish",
47 :occurred_at => page.updated_at,
48 :inferred_from => "from_page_revision",
49 :via => "draft",
50 :human_readable_node_name => diff[:title]["to"],
51 **diff)
52 created += 1
53 end
54 previous = page
55 end
56 end
57 end
58
59 puts "Created #{created} inferred entries"
60 end
61end
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js
index 9ce0b9e..3988016 100644
--- a/public/javascripts/admin_interface.js
+++ b/public/javascripts/admin_interface.js
@@ -415,8 +415,10 @@ rrule_builder = {
415 }); 415 });
416 $("#rrule_monthly_ordinal").bind("change", function() { 416 $("#rrule_monthly_ordinal").bind("change", function() {
417 $("#rrule_ordinal_fields").toggle($(this).is(":checked")); 417 $("#rrule_ordinal_fields").toggle($(this).is(":checked"));
418 rrule_builder.toggle_custom_ordinal_fields();
418 rrule_builder.sync(); 419 rrule_builder.sync();
419 }); 420 });
421 $("#rrule_ordinal").bind("change", rrule_builder.toggle_custom_ordinal_fields);
420 $("#rrule_exclude_month").bind("change", function() { 422 $("#rrule_exclude_month").bind("change", function() {
421 $("#rrule_excluded_month").toggle($(this).is(":checked")); 423 $("#rrule_excluded_month").toggle($(this).is(":checked"));
422 rrule_builder.sync(); 424 rrule_builder.sync();
@@ -433,6 +435,12 @@ rrule_builder = {
433 $("#rrule_monthly_options").show(); 435 $("#rrule_monthly_options").show();
434 }, 436 },
435 437
438 toggle_custom_ordinal_fields : function() {
439 $("#rrule_custom_ordinal_fields").toggle(
440 $("#rrule_monthly_ordinal").is(":checked") && $("#rrule_ordinal").val() === "custom"
441 );
442 },
443
436 sync : function() { 444 sync : function() {
437 var freq = $("input[name='rrule_freq']:checked").val(); 445 var freq = $("input[name='rrule_freq']:checked").val();
438 var parts = []; 446 var parts = [];
@@ -448,7 +456,16 @@ rrule_builder = {
448 } else { 456 } else {
449 parts.push("FREQ=MONTHLY"); 457 parts.push("FREQ=MONTHLY");
450 if ($("#rrule_monthly_ordinal").is(":checked")) { 458 if ($("#rrule_monthly_ordinal").is(":checked")) {
451 parts.push("BYDAY=" + $("#rrule_ordinal").val() + $("#rrule_ordinal_day").val()); 459 var ordinal = $("#rrule_ordinal").val();
460 if (ordinal === "custom") {
461 var customDays = [];
462 $("input[id^='rrule_custom_ordinal_']:checked").each(function() {
463 customDays.push($(this).attr("id").replace("rrule_custom_ordinal_", "") + $("#rrule_ordinal_day").val());
464 });
465 if (customDays.length > 0) parts.push("BYDAY=" + customDays.join(","));
466 } else {
467 parts.push("BYDAY=" + ordinal + $("#rrule_ordinal_day").val());
468 }
452 } 469 }
453 } 470 }
454 471
@@ -483,8 +500,33 @@ rrule_builder = {
483 } else if (parts.FREQ === "MONTHLY") { 500 } else if (parts.FREQ === "MONTHLY") {
484 $("input[name='rrule_freq'][value='monthly']").prop("checked", true); 501 $("input[name='rrule_freq'][value='monthly']").prop("checked", true);
485 rrule_builder.show_monthly_options(); 502 rrule_builder.show_monthly_options();
503 var ordinalDays = parts.BYDAY && parts.BYDAY.split(",");
504 var customOrdinalDay = null;
505 var customOrdinals = [];
506 var customOrdinalsValid = ordinalDays && ordinalDays.length > 0;
507 if (customOrdinalsValid) {
508 ordinalDays.forEach(function(value) {
509 var customMatch = value.match(/^([1-5])([A-Z]{2})$/);
510 if (!customMatch || (customOrdinalDay && customOrdinalDay !== customMatch[2])) {
511 customOrdinalsValid = false;
512 return;
513 }
514 customOrdinalDay = customMatch[2];
515 customOrdinals.push(customMatch[1]);
516 });
517 }
518
486 var match = parts.BYDAY && parts.BYDAY.match(/^(-?\d+)([A-Z]{2})$/); 519 var match = parts.BYDAY && parts.BYDAY.match(/^(-?\d+)([A-Z]{2})$/);
487 if (match) { 520 if (customOrdinalsValid && (customOrdinals.length > 1 || customOrdinals[0] === "5")) {
521 $("#rrule_monthly_ordinal").prop("checked", true);
522 $("#rrule_ordinal_fields").show();
523 $("#rrule_ordinal").val("custom");
524 $("#rrule_ordinal_day").val(customOrdinalDay);
525 customOrdinals.forEach(function(ordinal) {
526 $("#rrule_custom_ordinal_" + ordinal).prop("checked", true);
527 });
528 rrule_builder.toggle_custom_ordinal_fields();
529 } else if (match) {
488 $("#rrule_monthly_ordinal").prop("checked", true); 530 $("#rrule_monthly_ordinal").prop("checked", true);
489 $("#rrule_ordinal_fields").show(); 531 $("#rrule_ordinal_fields").show();
490 $("#rrule_ordinal").val(match[1]); 532 $("#rrule_ordinal").val(match[1]);
diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js
index 7604bb7..d135503 100644
--- a/public/javascripts/admin_search.js
+++ b/public/javascripts/admin_search.js
@@ -72,24 +72,20 @@ function initSearchPicker(options) {
72 found = false; 72 found = false;
73 for (var i = 0; i < data.length; i++) { 73 for (var i = 0; i < data.length; i++) {
74 (function(node) { 74 (function(node) {
75 var link; 75 var anchor = $("<a>").attr("href", onSelect ? "#" : node.node_path);
76 anchor.append(document.createTextNode(node.title || ""));
77 anchor.append($("<span>", { "class": "result_path" }).text(node.unique_name || ""));
78 var link = $("<p>").append(anchor);
79
76 if (onSelect) { 80 if (onSelect) {
77 link = $( 81 anchor.bind("click", function() {
78 "<p><a href='#'>" + node.title +
79 "<span class='result_path'>" + node.unique_name + "</span></a></p>"
80 );
81 link.find("a").bind("click", function() {
82 onSelect(node); 82 onSelect(node);
83 results.slideUp(); 83 results.slideUp();
84 results.empty(); 84 results.empty();
85 return false; 85 return false;
86 }); 86 });
87 } else {
88 link = $(
89 "<p><a href='" + node.node_path + "'>" + node.title +
90 "<span class='result_path'>" + node.unique_name + "</span></a></p>"
91 );
92 } 87 }
88
93 results.append(link); 89 results.append(link);
94 found = true; 90 found = true;
95 })(data[i]); 91 })(data[i]);
@@ -150,7 +146,9 @@ dashboard_search = {
150 results.append("<p class='search_group_label'>Tags</p>"); 146 results.append("<p class='search_group_label'>Tags</p>");
151 var tag_row = $("<div class='search_tag_row'></div>"); 147 var tag_row = $("<div class='search_tag_row'></div>");
152 data.tags.forEach(function(tag) { 148 data.tags.forEach(function(tag) {
153 tag_row.append("<a class='search_tag_pill' href='" + tag.tag_path + "'>" + tag.name + "</a>"); 149 tag_row.append(
150 $("<a>", { "class": "search_tag_pill" }).attr("href", tag.tag_path).text(tag.name || "")
151 );
154 found = true; 152 found = true;
155 }); 153 });
156 results.append(tag_row); 154 results.append(tag_row);
@@ -159,10 +157,10 @@ dashboard_search = {
159 if (data.nodes.length) { 157 if (data.nodes.length) {
160 results.append("<p class='search_group_label'>Pages</p>"); 158 results.append("<p class='search_group_label'>Pages</p>");
161 data.nodes.forEach(function(node) { 159 data.nodes.forEach(function(node) {
162 results.append( 160 var anchor = $("<a>").attr("href", node.node_path);
163 "<p><a href='" + node.node_path + "'>" + node.title + 161 anchor.append(document.createTextNode(node.title || ""));
164 "<span class='result_path'>" + node.unique_name + "</span></a></p>" 162 anchor.append($("<span>", { "class": "result_path" }).text(node.unique_name || ""));
165 ); 163 results.append($("<p>").append(anchor));
166 found = true; 164 found = true;
167 }); 165 });
168 } 166 }
diff --git a/public/javascripts/related_assets.js b/public/javascripts/related_assets.js
index 803332f..3340fa7 100644
--- a/public/javascripts/related_assets.js
+++ b/public/javascripts/related_assets.js
@@ -15,12 +15,9 @@ related_assets = {
15 renderResults: function(data, results) { 15 renderResults: function(data, results) {
16 var found = false; 16 var found = false;
17 data.forEach(function(asset) { 17 data.forEach(function(asset) {
18 var item = $( 18 var item = $("<a>", { href: "#", "class": "related_asset_result" });
19 "<a href='#' class='related_asset_result'>" + 19 item.append($("<img>", { src: asset.thumb_url, alt: "" }));
20 "<img src='" + asset.thumb_url + "' alt=''>" + 20 item.append($("<span>").text(asset.name || ""));
21 "<span>" + asset.name + "</span>" +
22 "</a>"
23 );
24 item.bind("click", function() { 21 item.bind("click", function() {
25 related_assets.attach(asset.id, createUrl, list); 22 related_assets.attach(asset.id, createUrl, list);
26 return false; 23 return false;
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index 5f0b165..128933b 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -37,10 +37,13 @@ td {
37 depend on this rule for vertical padding. table.node_table td resets 37 depend on this rule for vertical padding. table.node_table td resets
38 padding-top/bottom to 0 explicitly and does not depend on it. */ 38 padding-top/bottom to 0 explicitly and does not depend on it. */
39 39
40input[type=text], textarea { 40input[type=text],
41input[type=password],
42textarea {
41 font-size: 1rem; 43 font-size: 1rem;
42 font-family: Helvetica; 44 font-family: Helvetica;
43 border: 1px solid #989898; 45 border: 1px solid #989898;
46 border-radius: 2px;
44} 47}
45 48
46select { 49select {
@@ -1494,3 +1497,45 @@ div#image_browser ul li {
1494 gap: 8px; 1497 gap: 8px;
1495 flex-wrap: wrap; 1498 flex-wrap: wrap;
1496} 1499}
1500
1501/* ============================================================
1502 Action log
1503 ============================================================ */
1504
1505#dashboard_widget td.node_action_time {
1506 white-space: wrap;
1507}
1508
1509#node_action_list td.node_action_body {
1510 border-bottom: 1px solid #f1f1f1;
1511}
1512
1513#node_action_list td.node_action_time {
1514 white-space: nowrap;
1515 vertical-align: top;
1516 font-family: monospace;
1517 font-size: 0.8em;
1518 color: #777;
1519 padding-right: 1em;
1520}
1521
1522#node_action_list td.node_action_body {
1523 vertical-align: top;
1524}
1525
1526.node_action_inferred {
1527 opacity: 0.5;
1528 font-size: smaller;
1529 margin-left: 0.5em;
1530}
1531
1532.node_action_zoom {
1533 font-size: smaller;
1534 margin-left: 0.5em;
1535}
1536
1537.node_action_details summary {
1538 cursor: pointer;
1539 font-size: smaller;
1540 color: #777;
1541}
diff --git a/test/controllers/node_actions_controller_test.rb b/test/controllers/node_actions_controller_test.rb
new file mode 100644
index 0000000..8bc68ec
--- /dev/null
+++ b/test/controllers/node_actions_controller_test.rb
@@ -0,0 +1,51 @@
1require 'test_helper'
2
3class NodeActionsControllerTest < ActionController::TestCase
4
5 def setup
6 login_as :quentin
7 end
8
9 def logged_node slug, title
10 node = Node.root.children.create!(:slug => slug)
11 Globalize.with_locale(I18n.default_locale) { node.draft.update!(:title => title) }
12 node
13 end
14
15 test "index renders" do
16 get :index
17 assert_response :success
18 end
19
20 test "index filters by node" do
21 node_a = logged_node("log_filter_a", "Alpha Subject")
22 node_b = logged_node("log_filter_b", "Beta Subject")
23 NodeAction.record!(:node => node_a, :action => "create", :user => users(:quentin))
24 NodeAction.record!(:node => node_b, :action => "create", :user => users(:quentin))
25
26 get :index, params: { :node_id => node_a.id }
27
28 assert_response :success
29 assert_includes @response.body, "Alpha Subject"
30 assert_not_includes @response.body, "Beta Subject"
31 end
32
33 test "index filters by user" do
34 node = logged_node("log_filter_user", "Gamma Subject")
35 NodeAction.record!(:node => node, :action => "create", :user => users(:quentin))
36
37 get :index, params: { :user_id => users(:quentin).id }
38 assert_includes @response.body, "Gamma Subject"
39
40 other = User.where.not(:id => users(:quentin).id).first
41 get :index, params: { :user_id => other.id }
42 assert_not_includes @response.body, "Gamma Subject"
43 end
44
45 test "index requires login" do
46 session[:user_id] = nil
47 @controller.instance_variable_set(:@current_user, nil)
48 get :index
49 assert_response :redirect
50 end
51end
diff --git a/test/models/concerns/rrule_humanizer_test.rb b/test/models/concerns/rrule_humanizer_test.rb
index 279ff73..54c4c22 100644
--- a/test/models/concerns/rrule_humanizer_test.rb
+++ b/test/models/concerns/rrule_humanizer_test.rb
@@ -47,6 +47,24 @@ class RruleHumanizerTest < ActiveSupport::TestCase
47 assert_equal "Every second-to-last Thursday of the month", humanize("FREQ=MONTHLY;BYDAY=-2TH", :en) 47 assert_equal "Every second-to-last Thursday of the month", humanize("FREQ=MONTHLY;BYDAY=-2TH", :en)
48 end 48 end
49 49
50 test "monthly selected weeks" do
51 assert_equal "Jeden ersten, zweiten und fünften Dienstag im Monat",
52 humanize("FREQ=MONTHLY;BYDAY=1TU,2TU,5TU")
53 assert_equal "Every first, second and fifth Tuesday of the month",
54 humanize("FREQ=MONTHLY;BYDAY=1TU,2TU,5TU", :en)
55 end
56
57 test "monthly fifth weekday" do
58 assert_equal "Jeden fünften Dienstag im Monat", humanize("FREQ=MONTHLY;BYDAY=5TU")
59 assert_equal "Every fifth Tuesday of the month", humanize("FREQ=MONTHLY;BYDAY=5TU", :en)
60 end
61
62 test "monthly weekday excluding one ordinal" do
63 rrule = "FREQ=MONTHLY;BYDAY=1TU,3TU,4TU,5TU"
64 assert_equal "Jeden Dienstag im Monat, außer dem zweiten Dienstag", humanize(rrule)
65 assert_equal "Every Tuesday of the month, except the second Tuesday", humanize(rrule, :en)
66 end
67
50 test "monthly no byday" do 68 test "monthly no byday" do
51 assert_equal "Monatlich", humanize("FREQ=MONTHLY") 69 assert_equal "Monatlich", humanize("FREQ=MONTHLY")
52 assert_equal "Monthly", humanize("FREQ=MONTHLY", :en) 70 assert_equal "Monthly", humanize("FREQ=MONTHLY", :en)
diff --git a/test/models/helpers/node_actions_helper_test.rb b/test/models/helpers/node_actions_helper_test.rb
new file mode 100644
index 0000000..5244c19
--- /dev/null
+++ b/test/models/helpers/node_actions_helper_test.rb
@@ -0,0 +1,107 @@
1require 'test_helper'
2
3class NodeActionsHelperTest < ActionView::TestCase
4 def setup
5 @original_locale = I18n.locale
6 I18n.locale = :en
7 end
8
9 def default_url_options
10 { :locale => nil }
11 end
12
13 def teardown
14 I18n.locale = @original_locale
15 end
16
17 def entry action, metadata = {}, node: nil, user: nil, page: nil
18 NodeAction.create!(:node => node, :user => user, :page => page,
19 :action => action, :occurred_at => Time.now,
20 :metadata => { "username" => "quentin",
21 "human_readable_node_name" => "Subject" }.merge(metadata))
22 end
23
24 test "publish renders the revision sentence, not the titles" do
25 out = action_summary(entry("publish",
26 { "via" => "draft", "title" => { "from" => "Old", "to" => "New" } }))
27
28 assert_includes out, "quentin"
29 assert_includes out, "new revision"
30 assert_not_includes out, "Old"
31 end
32
33 test "first publish uses its own sentence" do
34 out = action_summary(entry("publish",
35 { "via" => "draft", "title" => { "from" => nil, "to" => "New" } }))
36
37 assert_includes out, "for the first time"
38 end
39
40 test "rollback publishes get the rollback sentence" do
41 out = action_summary(entry("publish",
42 { "via" => "revision", "title" => { "from" => "Now", "to" => "Then" } }))
43
44 assert_includes out, "rolled"
45 end
46
47 test "move renders the path pair" do
48 out = action_summary(entry("move",
49 { "path" => { "from" => "a/b", "to" => "a/c" } }))
50
51 assert_includes out, "a/b"
52 assert_includes out, "a/c"
53 end
54
55 test "unknown verbs degrade to a generic sentence, never an error" do
56 out = action_summary(entry("frobnicate"))
57
58 assert_includes out, "frobnicate"
59 assert_includes out, "quentin"
60 end
61
62 test "dead references render as plain names from metadata, no links" do
63 out = action_summary(entry("publish",
64 { "title" => { "from" => "Old", "to" => "New" } }))
65
66 assert_not_includes out, "<a "
67 assert_includes out, "Subject"
68 end
69
70 test "metadata values are escaped" do
71 out = action_summary(entry("publish",
72 { "human_readable_node_name" => "<b>bold</b>" }))
73
74 assert_not_includes out, "<b>"
75 end
76
77 test "live associations upgrade names to links" do
78 out = action_summary(entry("publish",
79 { "title" => { "from" => "Old", "to" => "New" } },
80 :user => users(:quentin)))
81
82 assert_includes out, "<a "
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
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