redmineup 1.1.17 → 1.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7ef56cb9d7465fe5dab138bb27ff1c61bc0e8ca150e7bbaed7f2251deeb7291
4
- data.tar.gz: da0ed59b5e23cc244d63ef70793d6b37a374254f256f2520d39d8a1e8d6df74c
3
+ metadata.gz: 506b754be350ea6e3591880f2d292171d54d6085f1c02bc16cfa5f42b0913bc1
4
+ data.tar.gz: 0400ce05a2c86b9f5704333ff05eb11faef0d6377efa81b42c33ca5b9abde33a
5
5
  SHA512:
6
- metadata.gz: d5499b14f2026f9fab97e79a7effc512ba1c74129d2d255e85ce7d19992cd19b6fe0c2f7fd4d44d30eb6ad2fc241ab7447b9df28446ca9262c6388968bba3d7e
7
- data.tar.gz: 2072701fba9e5b4b147e7221058fa1afbc35acaf292c1330a286df888122aefa04817d58a05fb1271963cf97f3ce5339454789e174ab77bf7f02267fa0959858
6
+ metadata.gz: c1866918b5aed3b47aeb0a5b0eb74f65172ad383171bc82e434bc26346300948303d08a9b2c60932b044d94da6ba5053ee76c49e425c5cbab328b8225ca473fe
7
+ data.tar.gz: 1f278a0fe544255d064352e25aa1faaca6ac15b42a1167ec82053acf650b7c2d902b9466f8b98fdb5001428b3b53626fcba787db93b0cb399996b3538db703a5
data/Rakefile CHANGED
@@ -6,6 +6,6 @@ task default: :test
6
6
  desc 'Run tests'
7
7
  Rake::TestTask.new(:test) do |t|
8
8
  t.libs << 'test'
9
- t.pattern = 'test/**/*_test.rb'
9
+ t.test_files = FileList['test/**/*_test.rb'].exclude('test/redmine/**/*')
10
10
  t.verbose = true
11
11
  end
@@ -260,9 +260,7 @@
260
260
  padding: 0 3px;
261
261
  }
262
262
  .rup-tag-select.select2-container--default .select2-selection--multiple .select2-selection__choice {
263
- border: 1px solid rgba(0, 0, 0, 0.2);
264
- border-radius: 3px;
265
- font-size: 10px;
263
+ font-size: 0.65rem;
266
264
  line-height: 1.3;
267
265
  padding: 2px 4px;
268
266
  }
@@ -397,27 +395,28 @@ div.tags-cloud { text-align: center; }
397
395
  border-radius: 3px;
398
396
  padding: 2px 4px;
399
397
  display: inline-block;
400
- font-size: 0.85em;
398
+ font-size: 0.65rem;
401
399
  line-height: 1.3;
402
- margin: 0 0 5px 2px;
403
- overflow-wrap: anywhere;
400
+ margin: 0 2px 2px 0;
401
+ white-space: nowrap;
404
402
  max-width: 100%;
405
403
  box-sizing: border-box;
406
404
  vertical-align: top;
407
405
  }
408
406
 
409
- .tag-label-color { color: white; }
410
- .tag-label-plain { background-color: #efefef; color: #333; }
407
+ .tag-label-color, .tag-label-color a, .tag-label-color a:link, .tag-label-color a:visited { color: #fff !important; }
408
+ .tag-label-plain { background-color: #efefef; }
409
+ .tag-label-plain, .tag-label-plain a, .tag-label-plain a:link, .tag-label-plain a:visited { color: #333 !important; }
411
410
 
412
411
  .tag-label a, .tag-label-color a { color: inherit; text-decoration: none; font-weight: normal; }
413
412
  .tag-label a:hover, .tag-label a:focus, .tag-label-color a:hover, .tag-label-color a:focus { text-decoration: none; }
414
413
 
415
414
  .tag-label:hover, .tag-label-color:hover { filter: brightness(0.85); }
416
- table.list .tag-label, table.list .tag-label-color { margin-bottom: 0; }
417
415
 
418
416
  .tag-link a { font-weight: normal; }
419
417
 
420
418
  .tag_list { margin-top: 5px; display: inline-block; }
419
+ td.tags, .tag_list { white-space: normal; }
421
420
 
422
421
  span.tag-input { display: inline-block; }
423
422
 
@@ -217,8 +217,8 @@
217
217
  border-width: 0 4px 5px 4px;
218
218
  }
219
219
  .select2-container--default .select2-selection--multiple {
220
- background-color: white;
221
- border: 1px solid #ccc;
220
+ background-color: var(--oc-white, #fff);
221
+ border: 1px solid var(--oc-gray-4, #ced4da);
222
222
  border-radius: 3px;
223
223
  cursor: text;
224
224
  }
@@ -0,0 +1,33 @@
1
+ <% new_markup = up_journal_new_markup? %>
2
+ <% Journal.preload_journals_details_custom_fields(journals.to_a) %>
3
+ <div class="journals">
4
+ <% journals.to_a.each do |journal| %>
5
+ <div id="change-<%= journal.id %>" class="<%= journal.css_classes %>">
6
+ <div class="note">
7
+ <% if new_markup %>
8
+ <h4 class="journal-header">
9
+ <span class="journal-info">
10
+ <%= avatar(journal.user) %>
11
+ <%= authoring journal.created_on, journal.user, label: :label_updated_time_by %>
12
+ </span>
13
+ </h4>
14
+ <% else %>
15
+ <h4 class="note-header">
16
+ <%= avatar(journal.user) %>
17
+ <%= authoring journal.created_on, journal.user, label: :label_updated_time_by %>
18
+ </h4>
19
+ <% end %>
20
+ <%= content_tag('div', class: ('journal-content' if new_markup)) do %>
21
+ <% if journal.visible_details.any? %>
22
+ <%= content_tag('ul', class: new_markup ? 'journal-details' : 'details') do %>
23
+ <% journal.visible_details.each do |detail| %>
24
+ <li><%= up_journal_detail(detail, journal) %></li>
25
+ <% end %>
26
+ <% end %>
27
+ <% end %>
28
+ <%= content_tag('div', textilizable(journal, :notes), class: 'wiki') if show_notes && journal.notes.present? %>
29
+ <% end %>
30
+ </div>
31
+ </div>
32
+ <% end %>
33
+ </div>
data/doc/CHANGELOG CHANGED
@@ -4,6 +4,13 @@ Redmine UP gem - general functions for plugins (tags, vote, viewing, currency)
4
4
  Copyright (C) 2011-2026 Kirill Bezrukov (RedmineUP)
5
5
  https://www.redmineup.com/
6
6
 
7
+ == 2026-10-28 v1.1.18
8
+
9
+ * Added up_acts_as_journalized: change history for any model
10
+ * Added open_only option to Issue available_tags
11
+ * Updated tags: no commas between chips
12
+ * Fixed tags text color and wrapping in tables
13
+
7
14
  == 2026-10-24 v1.1.17
8
15
 
9
16
  * Fixed gem breaking Redmine 5.0-6.0 on start (Norwegian locale)
@@ -0,0 +1,16 @@
1
+ module Redmineup
2
+ module ActsAsJournalized
3
+ class Hook < Redmine::Hook::Listener
4
+ def after_plugins_loaded(_context)
5
+ return unless Gem::Version.new(Redmine::VERSION.to_s) >= Gem::Version.new('5.0')
6
+
7
+ require File.expand_path('journal_patch', __dir__)
8
+ require File.expand_path('journal_event', __dir__)
9
+ ::Journal.prepend(JournalPatch) unless ::Journal.include?(JournalPatch)
10
+ Redmineup::ActsAsJournalized.journalized_classes.each do |klass|
11
+ Redmineup::ActsAsJournalized.register_activity(klass) if klass.up_journalized_options[:activity]
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,30 @@
1
+ module Redmineup
2
+ class JournalEvent < ::Journal
3
+ def self.visible(user = User.current, options = {})
4
+ visible_for(user, options)
5
+ end
6
+
7
+ def event_title
8
+ title = up_activity_options[:title]
9
+ title ? title.call(self) : journalized.to_s
10
+ end
11
+
12
+ def event_url(options = {})
13
+ up_activity_options[:url].call(self).merge(options)
14
+ end
15
+
16
+ def event_type
17
+ up_activity_options[:css_type] || "#{journalized_type.underscore.dasherize}-edit"
18
+ end
19
+
20
+ def event_group
21
+ journalized
22
+ end
23
+
24
+ private
25
+
26
+ def up_activity_options
27
+ journalized.up_journalized_options[:activity]
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,87 @@
1
+ module Redmineup
2
+ module ActsAsJournalized
3
+ module JournalPatch
4
+ def self.prepended(base)
5
+ base.extend(ClassMethods)
6
+ base.singleton_class.prepend(IssueOnlyVisible)
7
+ end
8
+
9
+ module IssueOnlyVisible
10
+ def visible(*)
11
+ super.where(journalized_type: 'Issue')
12
+ end
13
+ end
14
+
15
+ module ClassMethods
16
+ def up_project_ids(options)
17
+ project = options[:project]
18
+ return [project.id] unless options.fetch(:with_subprojects, Setting.display_subprojects_issues?)
19
+
20
+ project.self_and_descendants.ids
21
+ end
22
+
23
+ def visible_for(user = User.current, options = {})
24
+ conditions = Redmineup::ActsAsJournalized.journalized_classes.map do |klass|
25
+ raise ArgumentError, "#{klass.name} must define scope :visible" unless klass.respond_to?(:visible)
26
+
27
+ visible = klass.visible(user).reorder(nil)
28
+ visible = visible.where(klass.table_name => { project_id: up_project_ids(options) }) if options[:project] && klass.column_names.include?('project_id')
29
+ visible = visible.except(:eager_load).left_outer_joins(*visible.eager_load_values) if visible.eager_load_values.any?
30
+ sanitize_sql_array(
31
+ ["#{table_name}.journalized_type = ? AND #{table_name}.journalized_id IN (#{visible.select(klass.arel_table[:id]).to_sql})",
32
+ klass.base_class.name]
33
+ )
34
+ end
35
+ conditions.empty? ? none : where(conditions.join(' OR '))
36
+ end
37
+ end
38
+
39
+ def up_journalized?
40
+ journalized.respond_to?(:up_journalized?)
41
+ end
42
+
43
+ def up_start_blank
44
+ @attributes_before_change = {}
45
+ @custom_values_before_change = {}
46
+ end
47
+
48
+ def editable_by?(usr)
49
+ return super unless up_journalized?
50
+
51
+ permission = journalized.up_journalized_options[:edit_notes_permission]
52
+ return false unless permission && usr&.logged?
53
+
54
+ if permission.respond_to?(:call)
55
+ permission.call(self, usr)
56
+ else
57
+ usr.allowed_to?(permission, journalized.project)
58
+ end
59
+ end
60
+
61
+ def up_update_notes(usr, text)
62
+ return false unless editable_by?(usr)
63
+
64
+ self.notes = text
65
+ self.updated_by_id = usr.id if has_attribute?(:updated_by_id)
66
+ save
67
+ end
68
+
69
+ private
70
+
71
+ def add_watcher
72
+ super unless up_journalized?
73
+ end
74
+
75
+ def split_private_notes
76
+ up_journalized? || super
77
+ end
78
+
79
+ def send_notification
80
+ return super unless up_journalized?
81
+
82
+ notifier = journalized.up_journalized_options[:notify]
83
+ notifier&.call(self) if notify?
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,118 @@
1
+ module Redmineup
2
+ module ActsAsJournalized
3
+ EXCLUDED_ATTRIBUTES = %w(id created_on updated_on created_at updated_at lock_version).freeze
4
+ OPTION_KEYS = %i[association associations only except journalize_create activity notify edit_notes_permission detail_formatter].freeze
5
+
6
+ def self.registry
7
+ @registry ||= []
8
+ end
9
+
10
+ def self.journalized_classes
11
+ registry.filter_map(&:safe_constantize)
12
+ end
13
+
14
+ def self.register_activity(klass)
15
+ return unless defined?(::Redmineup::JournalEvent)
16
+
17
+ activity = klass.up_journalized_options[:activity]
18
+ type = (activity[:type] || "#{klass.name.underscore}_journals").to_s
19
+ class_name = klass.name
20
+ ::Redmineup::JournalEvent.acts_as_activity_provider(
21
+ type: type,
22
+ author_key: :user_id,
23
+ scope: -> { ::Redmineup::ActsAsJournalized.activity_scope(class_name.constantize) }
24
+ )
25
+ end
26
+
27
+ def self.activity_scope(klass)
28
+ keys = Array(klass.up_journalized_options[:activity][:keys]).map(&:to_s)
29
+ scope = ::Redmineup::JournalEvent.preload(:user, :journalized).where(journalized_type: klass.base_class.name)
30
+ notes = "#{::Journal.table_name}.notes <> ''"
31
+ return scope.where(notes) if keys.empty?
32
+
33
+ details = ::JournalDetail.table_name
34
+ scope.joins("LEFT OUTER JOIN #{details} ON #{details}.journal_id = #{::Journal.table_name}.id")
35
+ .where("#{notes} OR (#{details}.property = 'attr' AND #{details}.prop_key IN (?))", keys).distinct
36
+ end
37
+
38
+ def self.included(base)
39
+ base.extend(ClassMethods)
40
+ end
41
+
42
+ module ClassMethods
43
+ def up_acts_as_journalized(options = {})
44
+ options.assert_valid_keys(*OPTION_KEYS)
45
+ if options[:activity] && !options[:activity][:url].respond_to?(:call)
46
+ raise ArgumentError, 'up_acts_as_journalized: activity needs :url lambda'
47
+ end
48
+
49
+ class_attribute :up_journalized_options, instance_writer: false
50
+ self.up_journalized_options = options
51
+
52
+ has_many options.fetch(:association, :journals), as: :journalized, class_name: 'Journal', dependent: :destroy, inverse_of: :journalized
53
+ (options[:associations] || {}).each do |association, attributes|
54
+ attributes.each do |attribute|
55
+ define_method("#{association}_#{attribute}") { public_send(association)&.public_send(attribute) }
56
+ end
57
+ end
58
+ before_save :up_prepare_journal
59
+ after_save :up_save_journal
60
+ include InstanceMethods
61
+ include NoCustomFields unless method_defined?(:custom_field_values)
62
+
63
+ Redmineup::ActsAsJournalized.registry << name unless Redmineup::ActsAsJournalized.registry.include?(name)
64
+ Redmineup::ActsAsJournalized.register_activity(self) if options[:activity]
65
+ end
66
+ end
67
+
68
+ module NoCustomFields
69
+ def custom_field_values
70
+ []
71
+ end
72
+ end
73
+
74
+ module InstanceMethods
75
+ def up_journalized?
76
+ true
77
+ end
78
+
79
+ def init_journal(user = User.current, notes = '')
80
+ @current_journal ||= Journal.new(journalized: self, user: user, notes: notes).tap do
81
+ @tag_list = Redmineup::ActsAsTaggable::TagList.new(*@tag_list) if @tag_list
82
+ end
83
+ end
84
+
85
+ def current_journal
86
+ @current_journal
87
+ end
88
+
89
+ def clear_journal
90
+ @current_journal = nil
91
+ end
92
+
93
+ def journalized_attribute_names
94
+ options = up_journalized_options
95
+ names = self.class.column_names - EXCLUDED_ATTRIBUTES - Array(options[:except]).map(&:to_s)
96
+ names = names.reject { |name| name.end_with?('_count') }
97
+ names &= Array(options[:only]).map(&:to_s) if options[:only]
98
+ names << 'tag_list' if respond_to?(:tag_list) && !Array(options[:except]).map(&:to_s).include?('tag_list')
99
+ names + (options[:associations] || {}).flat_map { |association, attributes| attributes.map { |attribute| "#{association}_#{attribute}" } }
100
+ end
101
+
102
+ private
103
+
104
+ def up_prepare_journal
105
+ current_journal.up_start_blank if current_journal && new_record? && up_journalized_options[:journalize_create]
106
+ end
107
+
108
+ def up_save_journal
109
+ return unless current_journal
110
+
111
+ current_journal.save
112
+ clear_journal
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ ActiveRecord::Base.include Redmineup::ActsAsJournalized
@@ -156,6 +156,11 @@ module Redmineup
156
156
  projects_joined = true
157
157
  end
158
158
 
159
+ if options[:open_only] && reflect_on_association(:status)&.klass == IssueStatus
160
+ scope = scope.joins("JOIN #{IssueStatus.table_name} ON #{IssueStatus.table_name}.id = #{table_name}.status_id").
161
+ where(IssueStatus.table_name => { is_closed: false })
162
+ end
163
+
159
164
  if base_class.respond_to?(:visible_condition)
160
165
  visible_condition = base_class.visible_condition(User.current)
161
166
  if visible_condition.include?('project_id') && !projects_joined
@@ -9,6 +9,8 @@ module Redmineup
9
9
  require File.expand_path('../redmineup/patches/auto_completes_controller_patch', __dir__)
10
10
 
11
11
  require File.expand_path('../redmineup/hooks/views_layouts_hook', __dir__)
12
+
13
+ require File.expand_path('../redmineup/acts_as_journalized/hook', __dir__)
12
14
  end
13
15
  end
14
16
  end
@@ -0,0 +1,53 @@
1
+ module Redmineup
2
+ module JournalsHelper
3
+ def up_journal_detail(detail, journal, options = {})
4
+ formatter = journal.journalized.up_journalized_options[:detail_formatter]
5
+ text = formatter&.call(detail, journal)
6
+ return text if text
7
+
8
+ label, old_value, value = up_journal_detail_values(detail, journal)
9
+ text =
10
+ if detail.old_value.blank? && detail.value.present?
11
+ l(:text_journal_set_to, label: label, value: value)
12
+ elsif detail.value.blank?
13
+ l(:text_journal_deleted, label: label, old: old_value)
14
+ else
15
+ l(:text_journal_changed, label: label, old: old_value, new: value)
16
+ end
17
+ options[:no_html] ? text : text.html_safe
18
+ end
19
+
20
+ def up_render_journal_history(journals, show_notes: true)
21
+ render partial: 'redmineup/journals/history', locals: { journals: journals, show_notes: show_notes }
22
+ end
23
+
24
+ def up_journal_new_markup?
25
+ Gem::Version.new(Redmine::VERSION.to_s) >= Gem::Version.new('6.1')
26
+ end
27
+
28
+ private
29
+
30
+ def up_journal_detail_values(detail, journal)
31
+ record = journal.journalized
32
+ if detail.property == 'cf'
33
+ field = CustomField.find_by(id: detail.prop_key)
34
+ return [h(detail.prop_key), h(detail.old_value), h(detail.value)] unless field
35
+
36
+ format = ->(v) { v.blank? ? '' : field.format.formatted_value(self, field, v, record, false) }
37
+ [h(field.name), h(format.call(detail.old_value)), h(format.call(detail.value))]
38
+ else
39
+ key = detail.prop_key.to_s
40
+ name = key.delete_suffix('_id')
41
+ reflection = record.class.reflect_on_association(name) if name != key
42
+ format = lambda do |v|
43
+ next '' if v.blank?
44
+ next v.to_s unless reflection
45
+
46
+ target = reflection.klass.find_by(id: v)
47
+ target && (!target.respond_to?(:visible?) || target.visible?) ? target.to_s : v.to_s
48
+ end
49
+ [h(l("field_#{key}", default: l("field_#{name}", default: name.humanize))), h(format.call(detail.old_value)), h(format.call(detail.value))]
50
+ end
51
+ end
52
+ end
53
+ end
@@ -20,7 +20,7 @@ module Redmineup
20
20
  end
21
21
 
22
22
  def tag_separator
23
- monochrome? ? ', ' : ' '
23
+ ' '
24
24
  end
25
25
 
26
26
  def monochrome?
@@ -1,3 +1,3 @@
1
1
  module Redmineup
2
- VERSION = '1.1.17'
2
+ VERSION = '1.1.18'
3
3
  end
data/lib/redmineup.rb CHANGED
@@ -20,6 +20,7 @@ require 'redmineup/acts_as_votable/voter'
20
20
  require 'redmineup/acts_as_draftable/up_acts_as_draftable'
21
21
  require 'redmineup/acts_as_draftable/draft'
22
22
  require 'redmineup/acts_as_priceable/up_acts_as_priceable'
23
+ require 'redmineup/acts_as_journalized/up_acts_as_journalized'
23
24
 
24
25
  require 'redmineup/currency'
25
26
  require 'redmineup/helpers/tags_helper'
@@ -42,6 +43,7 @@ require 'redmineup/liquid/drops/version_drop'
42
43
  require 'redmineup/liquid/drops/custom_field_enumeration_drop'
43
44
 
44
45
  require 'redmineup/helpers/external_assets_helper'
46
+ require 'redmineup/helpers/journals_helper'
45
47
  require 'redmineup/helpers/form_tag_helper'
46
48
  require 'redmineup/helpers/calendars_helper'
47
49
  require 'redmineup/helpers/datetime_helper'
@@ -109,6 +111,7 @@ if defined?(ActionView::Base)
109
111
  ActionView::Base.send :include, Redmineup::CalendarsHelper
110
112
  ActionView::Base.send :include, Redmineup::ExternalAssetsHelper
111
113
  ActionView::Base.send :include, Redmineup::FormTagHelper
114
+ ActionView::Base.send :include, Redmineup::JournalsHelper
112
115
  ActionView::Base.send :include, Redmineup::TagsHelper
113
116
  end
114
117
 
@@ -0,0 +1,106 @@
1
+ require File.dirname(__FILE__) + '/../test_helper'
2
+
3
+ class UpActsAsJournalizedTest < ActiveSupport::TestCase
4
+ def setup
5
+ @user = User.first || User.create!(login: 'journalist')
6
+ @thing = JournalizedThing.create!(name: 'one', status: 'new')
7
+ end
8
+
9
+ def test_attribute_change_is_journaled
10
+ @thing.init_journal(@user, 'why')
11
+ @thing.update!(status: 'done')
12
+
13
+ journal = @thing.journals.last
14
+ assert_equal 'why', journal.notes
15
+ assert_equal [%w(status new done)], journal.details.map { |d| [d.prop_key, d.old_value, d.value] }
16
+ end
17
+
18
+ def test_repeated_edits_keep_every_step
19
+ %w(a b c).each do |status|
20
+ @thing.init_journal(@user)
21
+ @thing.update!(status: status)
22
+ end
23
+
24
+ assert_equal 3, @thing.journals.count
25
+ assert_equal [%w(new a), %w(a b), %w(b c)], @thing.journals.order(:id).map { |j| [j.details.first.old_value, j.details.first.value] }
26
+ end
27
+
28
+ def test_several_changes_make_one_journal
29
+ @thing.init_journal(@user)
30
+ @thing.update!(status: 'done', due_date: Date.new(2026, 1, 1))
31
+
32
+ assert_equal 1, @thing.journals.count
33
+ assert_equal %w(due_date status), @thing.journals.first.details.map(&:prop_key).sort
34
+ end
35
+
36
+ def test_notes_only_journal
37
+ @thing.init_journal(@user, 'just a note')
38
+ @thing.save!
39
+
40
+ assert_equal 1, @thing.journals.count
41
+ assert_empty @thing.journals.first.details
42
+ end
43
+
44
+ def test_unchanged_save_without_notes_makes_no_journal
45
+ @thing.init_journal(@user)
46
+ @thing.save!
47
+
48
+ assert_equal 0, @thing.journals.count
49
+ end
50
+
51
+ def test_no_journal_without_init_journal
52
+ @thing.update!(status: 'done')
53
+
54
+ assert_equal 0, @thing.journals.count
55
+ end
56
+
57
+ def test_rollback_leaves_no_journal
58
+ JournalizedThing.transaction do
59
+ @thing.init_journal(@user, 'x')
60
+ @thing.update!(status: 'done')
61
+ raise ActiveRecord::Rollback
62
+ end
63
+
64
+ assert_equal 0, Journal.where(journalized_type: 'JournalizedThing').count
65
+ end
66
+
67
+ def test_create_is_not_journaled_by_default
68
+ thing = JournalizedThing.new(name: 'two', status: 'new')
69
+ thing.init_journal(@user)
70
+ thing.save!
71
+
72
+ assert_equal 0, thing.journals.count
73
+ end
74
+
75
+ def test_excluded_and_counter_attributes_are_not_tracked
76
+ names = @thing.journalized_attribute_names
77
+
78
+ assert_includes names, 'status'
79
+ assert_not_includes names, 'id'
80
+ assert_not_includes names, 'updated_on'
81
+ assert_not_includes names, 'comments_count'
82
+ end
83
+
84
+ def test_destroy_deletes_journals
85
+ @thing.init_journal(@user, 'x')
86
+ @thing.update!(status: 'done')
87
+
88
+ assert_difference('Journal.count', -1) { @thing.destroy }
89
+ end
90
+
91
+ def test_unknown_option_is_rejected
92
+ assert_raises(ArgumentError) { Class.new(ActiveRecord::Base) { self.table_name = 'journalized_things'; up_acts_as_journalized foo: 1 } }
93
+ end
94
+
95
+ def test_activity_needs_url
96
+ assert_raises(ArgumentError) { Class.new(ActiveRecord::Base) { self.table_name = 'journalized_things'; up_acts_as_journalized activity: {} } }
97
+ end
98
+
99
+ def test_custom_field_values_fallback
100
+ assert_equal [], @thing.custom_field_values
101
+ end
102
+
103
+ def test_registered
104
+ assert_includes Redmineup::ActsAsJournalized.registry, 'JournalizedThing'
105
+ end
106
+ end
@@ -1,6 +1,44 @@
1
+ class JournalDetail < ActiveRecord::Base
2
+ belongs_to :journal
3
+ end
4
+
1
5
  class Journal < ActiveRecord::Base
2
6
  belongs_to :journalized, :polymorphic => true
3
7
  belongs_to :issue, :foreign_key => :journalized_id
4
8
 
5
9
  belongs_to :user
10
+ has_many :details, :class_name => 'JournalDetail', :dependent => :delete_all, :inverse_of => :journal
11
+
12
+ attr_accessor :notify
13
+
14
+ def initialize(*args)
15
+ super
16
+ if journalized
17
+ journalized.new_record? ? self.notify = false : start
18
+ end
19
+ end
20
+
21
+ def save(*args)
22
+ journalize_changes
23
+ (new_record? && notes.blank? && details.empty?) ? false : super()
24
+ end
25
+
26
+ def start
27
+ @attributes_before_change = journalized.journalized_attribute_names.index_with { |name| journalized.send(name) }
28
+ @custom_values_before_change = {}
29
+ self
30
+ end
31
+
32
+ def journalize_changes
33
+ if @attributes_before_change
34
+ (journalized.journalized_attribute_names + @attributes_before_change.keys).uniq.each do |name|
35
+ before = @attributes_before_change[name]
36
+ after = journalized.send(name)
37
+ next if before == after || (before.blank? && after.blank?)
38
+
39
+ details << JournalDetail.new(property: 'attr', prop_key: name, old_value: before, value: after)
40
+ end
41
+ end
42
+ start
43
+ end
6
44
  end
@@ -0,0 +1,5 @@
1
+ class JournalizedThing < ActiveRecord::Base
2
+ scope :visible, lambda { |*| where('1=1') }
3
+
4
+ up_acts_as_journalized except: :updated_on
5
+ end
@@ -0,0 +1,264 @@
1
+ require File.join(Dir.pwd, 'test/test_helper')
2
+
3
+ ActiveRecord::Base.connection.create_table :up_journalized_records, force: true do |t|
4
+ t.references :project
5
+ t.string :name
6
+ t.date :due_date
7
+ t.string :status
8
+ t.integer :comments_count, default: 0
9
+ t.datetime :created_on
10
+ t.datetime :updated_on
11
+ end
12
+ Minitest.after_run { ActiveRecord::Base.connection.drop_table :up_journalized_records, if_exists: true }
13
+
14
+ class UpJournalizedRecordCustomField < CustomField
15
+ def type_name
16
+ :label_project_plural
17
+ end
18
+ end
19
+
20
+ class UpJournalizedRecord < (defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base)
21
+ NOTIFIED = []
22
+
23
+ belongs_to :project
24
+ acts_as_customizable
25
+
26
+ scope :visible, lambda { |user = User.current, _options = {}|
27
+ joins(:project).where(Project.allowed_to_condition(user, :view_issues))
28
+ }
29
+
30
+ def visible?(user = User.current)
31
+ user.allowed_to?(:view_issues, project)
32
+ end
33
+
34
+ up_acts_as_journalized(
35
+ notify: ->(journal) { NOTIFIED << journal.id },
36
+ edit_notes_permission: :edit_issues,
37
+ activity: { type: 'up_records', url: ->(journal) { { controller: 'welcome' } }, title: ->(journal) { "Record #{journal.journalized.name}" } }
38
+ )
39
+ end
40
+
41
+ Redmine::Activity.register :up_records, class_name: 'Redmineup::JournalEvent'
42
+
43
+ class UpActsAsJournalizedRedmineTest < ActiveSupport::TestCase
44
+ fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles, :enabled_modules, :trackers,
45
+ :issue_statuses, :issues, :journals, :journal_details, :enumerations, :custom_fields, :custom_values,
46
+ :projects_trackers, :watchers, :issue_categories, :versions
47
+
48
+ def setup
49
+ User.current = nil
50
+ UpJournalizedRecord::NOTIFIED.clear
51
+ @project = Project.find(1)
52
+ @user = User.find(2)
53
+ @field = UpJournalizedRecordCustomField.create!(
54
+ name: 'Up tags', field_format: 'list', multiple: true, possible_values: %w(a b c), is_for_all: true
55
+ )
56
+ @record = UpJournalizedRecord.create!(project: @project, name: 'rec', status: 'new')
57
+ end
58
+
59
+ def test_attribute_and_notes
60
+ @record.init_journal(@user, 'changed')
61
+ @record.update!(status: 'done', due_date: Date.new(2026, 1, 2))
62
+
63
+ journal = @record.journals.last
64
+ assert_equal 'changed', journal.notes
65
+ assert_equal %w(due_date status), journal.details.map(&:prop_key).sort
66
+ assert_equal 'attr', journal.details.first.property
67
+ end
68
+
69
+ def test_multiple_value_custom_field
70
+ @record.reload
71
+ @record.init_journal(@user)
72
+ @record.custom_field_values = { @field.id.to_s => %w(a b) }
73
+ @record.save!
74
+
75
+ @record.reload
76
+ @record.init_journal(@user)
77
+ @record.custom_field_values = { @field.id.to_s => %w(b c) }
78
+ @record.save!
79
+
80
+ details = @record.journals.order(:id).flat_map { |j| j.details.map { |d| [d.property, d.old_value, d.value] } }
81
+ assert_includes details, ['cf', nil, 'a']
82
+ assert_includes details, ['cf', nil, 'b']
83
+ assert_includes details, ['cf', 'a', nil]
84
+ assert_includes details, ['cf', nil, 'c']
85
+ end
86
+
87
+ def test_notes_only
88
+ @record.init_journal(@user, 'only note')
89
+ @record.save!
90
+
91
+ assert_equal ['only note'], @record.journals.map(&:notes)
92
+ assert_empty @record.journals.first.details
93
+ end
94
+
95
+ def test_unchanged_save_without_notes_has_no_journal
96
+ @record.init_journal(@user)
97
+ @record.save!
98
+
99
+ assert_equal 0, @record.journals.count
100
+ end
101
+
102
+ def test_rollback_leaves_no_journal
103
+ UpJournalizedRecord.transaction do
104
+ @record.init_journal(@user, 'x')
105
+ @record.update!(status: 'done')
106
+ raise ActiveRecord::Rollback
107
+ end
108
+
109
+ assert_equal 0, Journal.where(journalized_type: 'UpJournalizedRecord').count
110
+ end
111
+
112
+ def test_create_with_journalize_create
113
+ UpJournalizedRecord.up_journalized_options = UpJournalizedRecord.up_journalized_options.merge(journalize_create: true)
114
+ record = UpJournalizedRecord.new(project: @project, name: 'new', status: 'open')
115
+ record.init_journal(@user)
116
+ record.save!
117
+
118
+ keys = record.journals.first.details.map(&:prop_key)
119
+ assert_includes keys, 'name'
120
+ assert_includes keys, 'status'
121
+ ensure
122
+ UpJournalizedRecord.up_journalized_options = UpJournalizedRecord.up_journalized_options.except(:journalize_create)
123
+ end
124
+
125
+ def test_no_watchers_and_no_issue_mail
126
+ ActionMailer::Base.deliveries.clear
127
+ assert_no_difference 'Watcher.count' do
128
+ @record.init_journal(@user, 'x')
129
+ @record.update!(status: 'done')
130
+ end
131
+ assert_empty ActionMailer::Base.deliveries
132
+ end
133
+
134
+ def test_notify_callback_runs_after_commit
135
+ @record.init_journal(@user, 'x')
136
+ @record.update!(status: 'done')
137
+
138
+ assert_equal [@record.journals.last.id], UpJournalizedRecord::NOTIFIED
139
+ end
140
+
141
+ def test_notify_callback_skipped_when_journal_notify_is_false
142
+ @record.init_journal(@user, 'x').notify = false
143
+ @record.update!(status: 'done')
144
+
145
+ assert_empty UpJournalizedRecord::NOTIFIED
146
+ end
147
+
148
+ def test_issue_journals_are_unaffected
149
+ ActionMailer::Base.deliveries.clear
150
+ Setting.notified_events = %w(issue_updated)
151
+ issue = Issue.find(1)
152
+ issue.init_journal(@user, 'issue note')
153
+ issue.update!(subject: 'changed subject')
154
+
155
+ assert_equal 'issue note', issue.journals.last.notes
156
+ assert_equal 'subject', issue.journals.last.details.first.prop_key
157
+ assert_not_empty ActionMailer::Base.deliveries
158
+ end
159
+
160
+ def test_issue_visible_scope_ignores_journals_of_other_types
161
+ @record.init_journal(@user, 'collision')
162
+ @record.save!
163
+ journal = @record.journals.last
164
+ Journal.where(id: journal.id).update_all(journalized_id: 1)
165
+
166
+ assert_not_includes Journal.visible(@user).pluck(:id), journal.id
167
+ assert_includes Journal.visible(@user).pluck(:id), Journal.where(journalized_type: 'Issue', journalized_id: 1).first.id
168
+ end
169
+
170
+ def test_history_hides_custom_field_details_of_invisible_fields
171
+ @field.update!(visible: false, role_ids: [])
172
+ @record.reload
173
+ @record.init_journal(@user)
174
+ @record.custom_field_values = { @field.id.to_s => %w(a) }
175
+ @record.save!
176
+
177
+ journal = @record.journals.last
178
+ assert_equal 1, journal.details.size
179
+ assert_empty journal.visible_details(User.find(2))
180
+ assert_equal 1, journal.visible_details(User.find(1)).size
181
+ end
182
+
183
+ def test_visible_for
184
+ hidden = UpJournalizedRecord.create!(project: Project.find(2), name: 'hidden')
185
+ [@record, hidden].each do |record|
186
+ record.init_journal(@user, 'n')
187
+ record.save!
188
+ end
189
+ Project.find(2).update!(is_public: false)
190
+ Member.where(project_id: 2, user_id: @user.id).delete_all
191
+
192
+ ids = Journal.visible_for(@user).pluck(:journalized_id, :journalized_type)
193
+ assert_includes ids, [@record.id, 'UpJournalizedRecord']
194
+ assert_not_includes ids, [hidden.id, 'UpJournalizedRecord']
195
+ assert Journal.visible_for(@user).all? { |j| j.journalized_type == 'UpJournalizedRecord' }
196
+ end
197
+
198
+ def test_activity_event
199
+ @record.init_journal(@user, 'visible note')
200
+ @record.save!
201
+
202
+ fetcher = Redmine::Activity::Fetcher.new(@user)
203
+ fetcher.scope = ['up_records']
204
+ event = fetcher.events(Date.today - 1, Date.today + 1).first
205
+
206
+ assert_equal "Record rec", event.event_title
207
+ assert_equal 'visible note', event.event_description
208
+ assert_equal @record, event.event_group
209
+ end
210
+
211
+ def test_activity_is_limited_to_project
212
+ other = UpJournalizedRecord.create!(project: Project.find(2), name: 'other')
213
+ [@record, other].each do |record|
214
+ record.init_journal(@user, 'n')
215
+ record.save!
216
+ end
217
+
218
+ events = Redmineup::JournalEvent.find_events('up_records', User.find(1), nil, nil, project: Project.find(2))
219
+ assert_equal [other], events.map(&:event_group)
220
+ end
221
+
222
+ def test_activity_only_notes_and_listed_keys
223
+ @record.init_journal(@user)
224
+ @record.update!(status: 'quiet')
225
+ fetcher = Redmine::Activity::Fetcher.new(@user)
226
+ fetcher.scope = ['up_records']
227
+ assert_empty fetcher.events(Date.today - 1, Date.today + 1)
228
+
229
+ UpJournalizedRecord.up_journalized_options = UpJournalizedRecord.up_journalized_options.merge(activity: UpJournalizedRecord.up_journalized_options[:activity].merge(keys: %w(status)))
230
+ @record.init_journal(@user)
231
+ @record.update!(status: 'loud')
232
+ assert_equal 2, fetcher.events(Date.today - 1, Date.today + 1).size
233
+ ensure
234
+ UpJournalizedRecord.up_journalized_options = UpJournalizedRecord.up_journalized_options.merge(activity: UpJournalizedRecord.up_journalized_options[:activity].except(:keys))
235
+ end
236
+
237
+ def test_editable_by_and_update_notes
238
+ @record.init_journal(@user, 'old')
239
+ @record.update!(status: 'x')
240
+ journal = @record.journals.last
241
+
242
+ assert journal.editable_by?(User.find(2))
243
+ assert_not journal.editable_by?(User.anonymous)
244
+ assert journal.up_update_notes(@user, 'new')
245
+ assert_equal 'new', journal.reload.notes
246
+ assert_equal @user.id, journal.updated_by_id if journal.has_attribute?(:updated_by_id)
247
+ end
248
+
249
+ def test_journal_detail_escapes_deleted_custom_field_values
250
+ detail = JournalDetail.new(property: 'cf', prop_key: '999999', old_value: '<b>old</b>', value: '<script>x</script>')
251
+ html = ApplicationController.new.view_context.up_journal_detail(detail, @record.journals.build)
252
+
253
+ assert_no_match(/<script>|<b>/, html)
254
+ end
255
+
256
+ def test_journal_detail_text
257
+ @record.init_journal(@user)
258
+ @record.update!(status: 'done')
259
+ detail = @record.journals.last.details.first
260
+
261
+ view = ApplicationController.new.view_context
262
+ assert_match(/new/, view.up_journal_detail(detail, @record.journals.last, no_html: true))
263
+ end
264
+ end
data/test/schema.rb CHANGED
@@ -34,6 +34,23 @@ ActiveRecord::Schema.define version: 0 do
34
34
  t.boolean :private_notes, default: false, null: false
35
35
  end
36
36
 
37
+ create_table :journal_details, force: true do |t|
38
+ t.integer :journal_id, default: 0, null: false
39
+ t.string :property, limit: 30, default: '', null: false
40
+ t.string :prop_key, limit: 30, default: '', null: false
41
+ t.text :old_value
42
+ t.text :value
43
+ end
44
+
45
+ create_table :journalized_things, force: true do |t|
46
+ t.string :name
47
+ t.date :due_date
48
+ t.string :status
49
+ t.integer :comments_count, default: 0
50
+ t.datetime :created_on
51
+ t.datetime :updated_on
52
+ end
53
+
37
54
  create_table :tags, force: true do |t|
38
55
  t.string :name
39
56
  t.string :color
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redmineup
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.17
4
+ version: 1.1.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - RedmineUP
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-24 00:00:00.000000000 Z
11
+ date: 2026-10-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -129,6 +129,7 @@ files:
129
129
  - app/controllers/redmineup_controller.rb
130
130
  - app/views/redmineup/_additional_assets.html.erb
131
131
  - app/views/redmineup/_money.html.erb
132
+ - app/views/redmineup/journals/_history.html.erb
132
133
  - app/views/redmineup/settings.html.erb
133
134
  - config/currency_iso.json
134
135
  - config/locales/ar.yml
@@ -194,6 +195,10 @@ files:
194
195
  - lib/redmineup.rb
195
196
  - lib/redmineup/acts_as_draftable/draft.rb
196
197
  - lib/redmineup/acts_as_draftable/up_acts_as_draftable.rb
198
+ - lib/redmineup/acts_as_journalized/hook.rb
199
+ - lib/redmineup/acts_as_journalized/journal_event.rb
200
+ - lib/redmineup/acts_as_journalized/journal_patch.rb
201
+ - lib/redmineup/acts_as_journalized/up_acts_as_journalized.rb
197
202
  - lib/redmineup/acts_as_list/list.rb
198
203
  - lib/redmineup/acts_as_priceable/up_acts_as_priceable.rb
199
204
  - lib/redmineup/acts_as_taggable/tag.rb
@@ -217,6 +222,7 @@ files:
217
222
  - lib/redmineup/helpers/datetime_helper.rb
218
223
  - lib/redmineup/helpers/external_assets_helper.rb
219
224
  - lib/redmineup/helpers/form_tag_helper.rb
225
+ - lib/redmineup/helpers/journals_helper.rb
220
226
  - lib/redmineup/helpers/tags_helper.rb
221
227
  - lib/redmineup/helpers/test_helper.rb
222
228
  - lib/redmineup/helpers/vote_helper.rb
@@ -248,6 +254,7 @@ files:
248
254
  - redmineup.gemspec
249
255
  - test/acts_as_draftable/draft_test.rb
250
256
  - test/acts_as_draftable/rup_acts_as_draftable_test.rb
257
+ - test/acts_as_journalized/up_acts_as_journalized_test.rb
251
258
  - test/acts_as_taggable/rup_acts_as_taggable_test.rb
252
259
  - test/acts_as_taggable/tag_list_test.rb
253
260
  - test/acts_as_taggable/tag_test.rb
@@ -285,11 +292,13 @@ files:
285
292
  - test/models/issue.rb
286
293
  - test/models/issue_relation.rb
287
294
  - test/models/journal.rb
295
+ - test/models/journalized_thing.rb
288
296
  - test/models/news.rb
289
297
  - test/models/project.rb
290
298
  - test/models/user.rb
291
299
  - test/models/vote_classes.rb
292
300
  - test/money_helper_test.rb
301
+ - test/redmine/acts_as_journalized/journalized_redmine_test.rb
293
302
  - test/schema.rb
294
303
  - test/tags_helper_test.rb
295
304
  - test/tags_helper_view_test.rb
@@ -321,6 +330,7 @@ summary: Common libraries for RedmineUP plugins for Redmine
321
330
  test_files:
322
331
  - test/acts_as_draftable/draft_test.rb
323
332
  - test/acts_as_draftable/rup_acts_as_draftable_test.rb
333
+ - test/acts_as_journalized/up_acts_as_journalized_test.rb
324
334
  - test/acts_as_taggable/rup_acts_as_taggable_test.rb
325
335
  - test/acts_as_taggable/tag_list_test.rb
326
336
  - test/acts_as_taggable/tag_test.rb
@@ -358,11 +368,13 @@ test_files:
358
368
  - test/models/issue.rb
359
369
  - test/models/issue_relation.rb
360
370
  - test/models/journal.rb
371
+ - test/models/journalized_thing.rb
361
372
  - test/models/news.rb
362
373
  - test/models/project.rb
363
374
  - test/models/user.rb
364
375
  - test/models/vote_classes.rb
365
376
  - test/money_helper_test.rb
377
+ - test/redmine/acts_as_journalized/journalized_redmine_test.rb
366
378
  - test/schema.rb
367
379
  - test/tags_helper_test.rb
368
380
  - test/tags_helper_view_test.rb