diff options
22 files changed, 619 insertions, 64 deletions
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 @@ | |||
| 1 | class 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 | ||
| 14 | end | ||
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 @@ | |||
| 1 | module 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 | ||
| 117 | end | ||
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/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: "…" | 82 | page_gap: "…" |
| 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: "…" | 34 | page_gap: "…" |
| 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/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/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/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 @@ | |||
| 1 | require 'test_helper' | ||
| 2 | |||
| 3 | class 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 | ||
| 51 | end | ||
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 @@ | |||
| 1 | require 'test_helper' | ||
| 2 | |||
| 3 | class 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 | ||
| 107 | end | ||
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 | ||
| 112 | end | 128 | end |
