additional_tags 1.0.0

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.
Files changed (94) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/brakeman.yml +38 -0
  3. data/.github/workflows/linters.yml +41 -0
  4. data/.github/workflows/tests.yml +139 -0
  5. data/.gitignore +8 -0
  6. data/.rubocop.yml +75 -0
  7. data/.slim-lint.yml +25 -0
  8. data/.stylelintrc.json +163 -0
  9. data/LICENSE +339 -0
  10. data/README.md +126 -0
  11. data/Rakefile +11 -0
  12. data/additional_tags.gemspec +28 -0
  13. data/app/controllers/additional_tags_controller.rb +80 -0
  14. data/app/controllers/issue_tags_controller.rb +48 -0
  15. data/app/helpers/additional_tags_helper.rb +197 -0
  16. data/app/helpers/additional_tags_issues_helper.rb +43 -0
  17. data/app/helpers/additional_tags_wiki_helper.rb +17 -0
  18. data/app/jobs/additional_tags_job.rb +3 -0
  19. data/app/jobs/additional_tags_remove_unused_tag_job.rb +5 -0
  20. data/app/models/migrate_tag.rb +4 -0
  21. data/app/models/migrate_tagging.rb +5 -0
  22. data/app/views/additional_tags/_html_head.html.slim +6 -0
  23. data/app/views/additional_tags/_tag_list.html.slim +35 -0
  24. data/app/views/additional_tags/context_menu.html.slim +16 -0
  25. data/app/views/additional_tags/edit.html.slim +14 -0
  26. data/app/views/additional_tags/merge.html.slim +17 -0
  27. data/app/views/additional_tags/settings/_general.html.slim +45 -0
  28. data/app/views/additional_tags/settings/_manage_tags.html.slim +35 -0
  29. data/app/views/additional_tags/settings/_settings.html.slim +9 -0
  30. data/app/views/auto_completes/_additional_tag_list.slim +1 -0
  31. data/app/views/context_menus/_issues_tags.html.slim +10 -0
  32. data/app/views/issue_tags/_edit_modal.html.slim +30 -0
  33. data/app/views/issue_tags/edit.js.erb +2 -0
  34. data/app/views/issues/_tags.html.slim +8 -0
  35. data/app/views/issues/_tags_bulk_edit.html.slim +7 -0
  36. data/app/views/issues/_tags_form.html.slim +11 -0
  37. data/app/views/issues/_tags_form_details.html.slim +7 -0
  38. data/app/views/issues/_tags_sidebar.html.slim +5 -0
  39. data/app/views/reports/_tags_simple.html.slim +11 -0
  40. data/app/views/wiki/_tags_form.html.slim +7 -0
  41. data/app/views/wiki/_tags_form_bottom.html.slim +5 -0
  42. data/app/views/wiki/_tags_show.html.slim +9 -0
  43. data/app/views/wiki/_tags_sidebar.html.slim +4 -0
  44. data/app/views/wiki/tag_index.html.slim +21 -0
  45. data/assets/javascripts/tags.js +19 -0
  46. data/assets/stylesheets/tags.css +119 -0
  47. data/config/locales/bg.yml +33 -0
  48. data/config/locales/cs.yml +33 -0
  49. data/config/locales/de.yml +33 -0
  50. data/config/locales/en.yml +33 -0
  51. data/config/locales/es.yml +33 -0
  52. data/config/locales/fr.yml +33 -0
  53. data/config/locales/it.yml +33 -0
  54. data/config/locales/ja.yml +33 -0
  55. data/config/locales/ko.yml +33 -0
  56. data/config/locales/pl.yml +33 -0
  57. data/config/locales/pt-BR.yml +33 -0
  58. data/config/locales/ru.yml +33 -0
  59. data/config/routes.rb +31 -0
  60. data/config/settings.yml +9 -0
  61. data/db/migrate/20201116145429_acts_as_taggable_migration.rb +40 -0
  62. data/db/migrate/20201123093214_migrate_existing_tags.rb +39 -0
  63. data/doc/images/additional-tags-framework.png +0 -0
  64. data/doc/images/additional-tags.gif +0 -0
  65. data/init.rb +31 -0
  66. data/lib/additional_tags.rb +107 -0
  67. data/lib/additional_tags/hooks.rb +63 -0
  68. data/lib/additional_tags/patches/agile_boards_controller_patch.rb +14 -0
  69. data/lib/additional_tags/patches/agile_query_patch.rb +41 -0
  70. data/lib/additional_tags/patches/agile_versions_controller_patch.rb +14 -0
  71. data/lib/additional_tags/patches/agile_versions_query_patch.rb +11 -0
  72. data/lib/additional_tags/patches/auto_completes_controller_patch.rb +42 -0
  73. data/lib/additional_tags/patches/calendars_controller_patch.rb +14 -0
  74. data/lib/additional_tags/patches/dashboard_async_blocks_controller_patch.rb +11 -0
  75. data/lib/additional_tags/patches/dashboards_controller_patch.rb +11 -0
  76. data/lib/additional_tags/patches/gantts_controller_patch.rb +14 -0
  77. data/lib/additional_tags/patches/imports_controller_patch.rb +12 -0
  78. data/lib/additional_tags/patches/issue_patch.rb +95 -0
  79. data/lib/additional_tags/patches/issue_query_patch.rb +55 -0
  80. data/lib/additional_tags/patches/issues_controller_patch.rb +14 -0
  81. data/lib/additional_tags/patches/journal_patch.rb +18 -0
  82. data/lib/additional_tags/patches/my_controller_patch.rb +14 -0
  83. data/lib/additional_tags/patches/queries_helper_patch.rb +40 -0
  84. data/lib/additional_tags/patches/reports_controller_patch.rb +32 -0
  85. data/lib/additional_tags/patches/settings_controller_patch.rb +12 -0
  86. data/lib/additional_tags/patches/time_entry_patch.rb +19 -0
  87. data/lib/additional_tags/patches/time_entry_query_patch.rb +53 -0
  88. data/lib/additional_tags/patches/time_report_patch.rb +46 -0
  89. data/lib/additional_tags/patches/timelog_controller_patch.rb +14 -0
  90. data/lib/additional_tags/patches/wiki_controller_patch.rb +63 -0
  91. data/lib/additional_tags/patches/wiki_page_patch.rb +39 -0
  92. data/lib/additional_tags/tags.rb +134 -0
  93. data/lib/additional_tags/version.rb +3 -0
  94. metadata +177 -0
@@ -0,0 +1,11 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new do |t|
5
+ t.libs << 'test'
6
+ files = FileList['test/**/*test.rb']
7
+ t.test_files = files
8
+ t.verbose = true
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,28 @@
1
+ lib = File.expand_path '../lib', __FILE__
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'additional_tags/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'additional_tags'
7
+ spec.version = AdditionalTags::VERSION
8
+ spec.authors = ['AlphaNodes']
9
+ spec.email = ['alex@alphanodes.com']
10
+
11
+ spec.summary = 'Redmine plugin for adding tag functionality'
12
+ spec.description = 'Redmine plugin for adding tag functionality'
13
+ spec.homepage = 'https://github.com/alphanodes/additional_tags'
14
+ spec.license = 'GPL-2.0'
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^((test|spec|features)/|Gemfile)})
18
+ end
19
+ spec.bindir = 'exe'
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ['lib']
22
+ spec.required_ruby_version = '>= 2.4'
23
+
24
+ spec.add_runtime_dependency 'acts-as-taggable-on', '~> 6.0'
25
+
26
+ spec.add_development_dependency 'bundler'
27
+ spec.add_development_dependency 'rake'
28
+ end
@@ -0,0 +1,80 @@
1
+ class AdditionalTagsController < ApplicationController
2
+ before_action :require_admin
3
+ before_action :find_tag, only: %i[edit update]
4
+ before_action :bulk_find_tags, only: %i[context_menu merge destroy]
5
+ before_action :set_tag_list_path
6
+
7
+ helper :additional_tags_issues
8
+
9
+ def edit; end
10
+
11
+ def destroy
12
+ @tags.each do |tag|
13
+ begin
14
+ tag.reload.destroy
15
+ rescue ::ActiveRecord::RecordNotFound
16
+ Rails.logger.warn "Tag #{tag} could not be deleted"
17
+ end
18
+ end
19
+ redirect_back_or_default @tag_list_path
20
+ end
21
+
22
+ def update
23
+ @tag.name = params[:tag][:name] if params[:tag]
24
+ if @tag.save
25
+ flash[:notice] = l :notice_successful_update
26
+ respond_to do |format|
27
+ format.html do
28
+ redirect_to @tag_list_path
29
+ end
30
+ format.xml
31
+ end
32
+ else
33
+ respond_to do |format|
34
+ format.html { render action: 'edit' }
35
+ end
36
+ end
37
+ end
38
+
39
+ def context_menu
40
+ @tag = @tags.first if @tags.size == 1
41
+ @back = back_url
42
+ render layout: false
43
+ end
44
+
45
+ def merge
46
+ return unless request.post? &&
47
+ params[:tag].present? &&
48
+ params[:tag][:name].present?
49
+
50
+ AdditionalTags::Tags.merge params[:tag][:name], @tags
51
+ redirect_to @tag_list_path
52
+ end
53
+
54
+ private
55
+
56
+ def set_tag_list_path
57
+ @tag_list_path = plugin_settings_path id: 'additional_tags', tab: 'manage_tags'
58
+ end
59
+
60
+ def bulk_find_tags
61
+ @tags = ActsAsTaggableOn::Tag.joins("JOIN #{ActiveRecord::Base.connection.quote_table_name ActsAsTaggableOn.taggings_table}" \
62
+ " ON #{ActiveRecord::Base.connection.quote_table_name ActsAsTaggableOn.taggings_table}.tag_id =" \
63
+ " #{ActiveRecord::Base.connection.quote_table_name ActsAsTaggableOn.tags_table}.id ")
64
+ .select("#{ActiveRecord::Base.connection.quote_table_name ActsAsTaggableOn.tags_table}.id," \
65
+ "#{ActiveRecord::Base.connection.quote_table_name ActsAsTaggableOn.tags_table}.name," \
66
+ "#{ActiveRecord::Base.connection.quote_table_name ActsAsTaggableOn.tags_table}.taggings_count," \
67
+ " COUNT(DISTINCT #{ActsAsTaggableOn.taggings_table}.taggable_id) AS count")
68
+ .where(id: params[:id] ? [params[:id]] : params[:ids])
69
+ .group("#{ActiveRecord::Base.connection.quote_table_name ActsAsTaggableOn.tags_table}.id" \
70
+ ", #{ActiveRecord::Base.connection.quote_table_name ActsAsTaggableOn.tags_table}.name" \
71
+ ", #{ActiveRecord::Base.connection.quote_table_name ActsAsTaggableOn.tags_table}.taggings_count")
72
+ raise ActiveRecord::RecordNotFound if @tags.empty?
73
+ end
74
+
75
+ def find_tag
76
+ @tag = ActsAsTaggableOn::Tag.find params[:id]
77
+ rescue ActiveRecord::RecordNotFound
78
+ render_404
79
+ end
80
+ end
@@ -0,0 +1,48 @@
1
+ class IssueTagsController < ApplicationController
2
+ before_action :find_issues, only: %i[edit update]
3
+
4
+ def edit
5
+ return unless AdditionalTags.setting?(:active_issue_tags) &&
6
+ User.current.allowed_to?(:edit_issue_tags, @projects.first)
7
+
8
+ @issue_ids = params[:ids]
9
+ @is_bulk_editing = @issue_ids.size > 1
10
+ @issue_tags = if @is_bulk_editing
11
+ issues = @issues.map(&:tag_list)
12
+ issues.flatten!
13
+ issues.uniq
14
+ else
15
+ @issues.first.tag_list
16
+ end
17
+
18
+ @issue_tags.sort!
19
+ @most_used_tags = Issue.available_tags.most_used 10
20
+ end
21
+
22
+ def update
23
+ if AdditionalTags.setting?(:active_issue_tags) &&
24
+ User.current.allowed_to?(:edit_issue_tags, @projects.first)
25
+ tags = params[:issue] && params[:issue][:tag_list] ? params[:issue][:tag_list].reject(&:empty?) : []
26
+
27
+ unless User.current.allowed_to?(:create_issue_tags, @projects.first) || Issue.allowed_tags?(tags)
28
+ flash[:error] = t(:notice_failed_to_add_tags)
29
+ return
30
+ end
31
+
32
+ Issue.transaction do
33
+ @issues.each do |issue|
34
+ issue.tag_list = tags
35
+ issue.save!
36
+ end
37
+ end
38
+ flash[:notice] = t(:notice_tags_added)
39
+ else
40
+ flash[:error] = t(:notice_failed_to_add_tags)
41
+ end
42
+ rescue StandardError => e
43
+ Rails.logger.warn "Failed to add tags: #{e.inspect}"
44
+ flash[:error] = t(:notice_failed_to_add_tags)
45
+ ensure
46
+ redirect_to_referer_or { render text: 'Tags updated.', layout: true }
47
+ end
48
+ end
@@ -0,0 +1,197 @@
1
+ require 'digest/md5'
2
+
3
+ module AdditionalTagsHelper
4
+ include ActsAsTaggableOn::TagsHelper
5
+
6
+ def manageable_tags
7
+ AdditionalTags::Tags.sort_tag_list ActsAsTaggableOn::Tag.where({})
8
+ end
9
+
10
+ def manageable_tag_columns
11
+ return @manageable_tag_columns if defined? @manageable_tag_columns
12
+
13
+ columns = {}
14
+
15
+ if AdditionalTags.setting?(:active_issue_tags)
16
+ columns[:issue] = { label: l(:label_issue_plural),
17
+ tag_controller: :issues,
18
+ counts: Issue.available_tags.map { |tag| [tag.id, tag.count] }.to_h }
19
+ end
20
+
21
+ if AdditionalTags.setting?(:active_wiki_tags)
22
+ columns[:wiki] = { label: l(:label_wiki),
23
+ counts: WikiPage.available_tags.map { |tag| [tag.id, tag.count] }.to_h }
24
+ end
25
+
26
+ call_hook :helper_additional_manageable_tag_columns, columns: columns
27
+
28
+ @manageable_tag_columns = columns
29
+ end
30
+
31
+ def manageable_tag_column_values(tag)
32
+ columns = []
33
+ manageable_tag_columns.each do |_column, column_values|
34
+ cnt = column_values[:counts][tag.id]
35
+ cnt = 0 if cnt.blank?
36
+
37
+ columns << if cnt.positive? && column_values[:tag_controller]
38
+ link_to cnt, tag_url(tag.name, tag_controller: column_values[:tag_controller])
39
+ else
40
+ cnt
41
+ end
42
+ end
43
+ columns
44
+ end
45
+
46
+ def render_tags_list(tags, options = {})
47
+ return if tags.blank?
48
+
49
+ style = options.delete(:style)
50
+ tags = tags.all.to_a if tags.respond_to?(:all)
51
+
52
+ case "#{AdditionalTags.setting :tags_sort_by}:#{AdditionalTags.setting :tags_sort_order}"
53
+ when 'name:desc'
54
+ tags = AdditionalTags::Tags.sort_tag_list(tags).reverse
55
+ when 'count:asc'
56
+ tags.sort! { |a, b| a.count <=> b.count }
57
+ when 'count:desc'
58
+ tags.sort! { |a, b| b.count <=> a.count }
59
+ else
60
+ tags = AdditionalTags::Tags.sort_tag_list tags
61
+ end
62
+
63
+ case style
64
+ when :list
65
+ list_el = 'ul'
66
+ item_el = 'li'
67
+ when :simple_cloud, :cloud
68
+ list_el = 'div'
69
+ item_el = 'span'
70
+ else
71
+ raise 'Unknown list style'
72
+ end
73
+
74
+ content = ''.html_safe
75
+ if style == :list && AdditionalTags.setting(:tags_sort_by) == 'name'
76
+ tags.group_by { |tag| tag.name.downcase.first }.each do |letter, grouped_tags|
77
+ content << content_tag(item_el, letter.upcase, class: 'letter')
78
+ add_tags style, grouped_tags, content, item_el, options
79
+ end
80
+ else
81
+ add_tags style, tags, content, item_el, options
82
+ end
83
+
84
+ content_tag(list_el, content, class: 'tags-cloud', style: (style == :simple_cloud ? 'text-align: left;' : ''))
85
+ end
86
+
87
+ def additional_tag_link(tag_object, options = {})
88
+ tag_name = []
89
+ tag_name << tag_object.name
90
+
91
+ options[:project] = @project if options[:project].blank? && @project.present?
92
+
93
+ use_colors = AdditionalTags.setting?(:use_colors)
94
+ if use_colors
95
+ tag_bg_color = additional_tag_color tag_object.name
96
+ tag_fg_color = additional_tag_fg_color tag_bg_color
97
+ tag_style = "background-color: #{tag_bg_color}; color: #{tag_fg_color}"
98
+ end
99
+
100
+ tag_name << tag.span("(#{tag_object.count})", class: 'tag-count') if options[:show_count]
101
+
102
+ content = if options[:link]
103
+ link_to safe_join(tag_name),
104
+ options[:link],
105
+ style: tag_style
106
+ elsif options[:link_wiki_tag]
107
+ link_to safe_join(tag_name),
108
+ project_wiki_index_path(options[:project], tag: tag_object.name),
109
+ style: tag_style
110
+ else
111
+ link_to safe_join(tag_name),
112
+ tag_url(tag_object.name, options),
113
+ style: tag_style
114
+ end
115
+
116
+ style = if use_colors
117
+ { class: 'additional-tag-label-color', style: tag_style }
118
+ else
119
+ { class: 'tag-label' }
120
+ end
121
+
122
+ tag.span content, style
123
+ end
124
+
125
+ def additional_tag_color(tag_name)
126
+ "##{Digest::MD5.hexdigest(tag_name)[0..5]}"
127
+ end
128
+
129
+ def additional_tag_fg_color(bg_color)
130
+ # calculate contrast text color according to YIQ method
131
+ # https://24ways.org/2010/calculating-color-contrast/
132
+ # https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color
133
+ r = bg_color[1..2].hex
134
+ g = bg_color[3..4].hex
135
+ b = bg_color[5..6].hex
136
+ (r * 299 + g * 587 + b * 114) >= 128_000 ? 'black' : 'white'
137
+ end
138
+
139
+ # plain list of tags
140
+ def additional_plain_tag_list(tags, sep = ' ')
141
+ s = if tags.blank?
142
+ ['']
143
+ else
144
+ tags.map(&:name)
145
+ end
146
+ s.join sep
147
+ end
148
+
149
+ def additional_tag_links(tag_list, options = {})
150
+ return if tag_list.blank?
151
+
152
+ sep = if options[:use_colors].nil? || options[:use_colors]
153
+ ' '
154
+ else
155
+ ', '
156
+ end
157
+
158
+ unsorted = options.delete(:unsorted)
159
+ tag_list = AdditionalTags::Tags.sort_tag_list(tag_list) unless unsorted
160
+
161
+ safe_join tag_list.map { |tag| additional_tag_link tag, options }, sep
162
+ end
163
+
164
+ private
165
+
166
+ def tag_url(tag_name, options = {})
167
+ action = options[:tag_action].presence || (controller_name == 'hrm_user_resources' ? 'show' : 'index')
168
+
169
+ fields = [:tags]
170
+ values = { tags: [tag_name] }
171
+ operators = { tags: '=' }
172
+
173
+ if options[:filter].present?
174
+ field = options[:filter][:field]
175
+ fields << field
176
+ operators[field] = options[:filter][:operator]
177
+ values[field] = options[:filter][:value] if options[:filter].key?(:value)
178
+ end
179
+
180
+ { controller: options[:tag_controller].presence || controller_name,
181
+ action: action,
182
+ set_filter: 1,
183
+ project_id: options[:project],
184
+ f: fields,
185
+ v: values,
186
+ op: operators }
187
+ end
188
+
189
+ def add_tags(style, tags, content, item_el, options)
190
+ tag_cloud tags, (1..8).to_a do |tag, weight|
191
+ content << ' '.html_safe + content_tag(item_el,
192
+ additional_tag_link(tag, options),
193
+ class: "tag-nube-#{weight}",
194
+ style: (style == :simple_cloud ? 'font-size: 1em;' : '')) + ' '.html_safe
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,43 @@
1
+ module AdditionalTagsIssuesHelper
2
+ # Hacked render_api_custom_values to add plugin values to issue api
3
+ def render_api_custom_values(custom_values, api)
4
+ rc = super
5
+
6
+ if @issue.present? &&
7
+ (defined?(controller_name) && controller_name == 'issues' && action_name == 'show' || !defined?(controller_name)) &&
8
+ User.current.allowed_to?(:view_issues, @project)
9
+
10
+ api.array :tags do
11
+ @issue.tags.each do |tag|
12
+ api.tag(id: tag.id, name: tag.name)
13
+ end
14
+ end
15
+ end
16
+
17
+ rc
18
+ end
19
+
20
+ def sidebar_tags
21
+ # we do not want tags on issue import
22
+ return if controller_name == 'imports'
23
+
24
+ unless @sidebar_tags
25
+ @sidebar_tags = []
26
+ if AdditionalTags.show_sidebar_tags?
27
+ @sidebar_tags = Issue.available_tags project: @project,
28
+ open_issues_only: AdditionalTags.setting?(:open_issues_only)
29
+ end
30
+ end
31
+ @sidebar_tags.to_a
32
+ end
33
+
34
+ def render_sidebar_tags
35
+ options = { show_count: AdditionalTags.setting?(:show_with_count),
36
+ filter: AdditionalTags.setting?(:open_issues_only) ? { field: :status_id, operator: 'o' } : nil,
37
+ style: AdditionalTags.setting(:tags_sidebar).to_sym,
38
+ project: @project }
39
+
40
+ options[:tag_action] = 'show' if %w[gantts calendars].include? controller_name
41
+ render_tags_list sidebar_tags, options
42
+ end
43
+ end
@@ -0,0 +1,17 @@
1
+ module AdditionalTagsWikiHelper
2
+ def sidebar_tags
3
+ unless @sidebar_tags
4
+ @sidebar_tags = []
5
+ @sidebar_tags = WikiPage.available_tags(project: @project) if AdditionalTags.show_sidebar_tags?
6
+ end
7
+ @sidebar_tags
8
+ end
9
+
10
+ def render_sidebar_tags
11
+ render_tags_list sidebar_tags,
12
+ show_count: AdditionalTags.setting?(:show_with_count),
13
+ style: AdditionalTags.setting(:tags_sidebar).to_sym,
14
+ link_wiki_tag: true,
15
+ project: @project
16
+ end
17
+ end
@@ -0,0 +1,3 @@
1
+ class AdditionalTagsJob < AdditionalsJob
2
+ queue_as :additional_tags
3
+ end
@@ -0,0 +1,5 @@
1
+ class AdditionalTagsRemoveUnusedTagJob < AdditionalTagsJob
2
+ def perform
3
+ AdditionalTags::Tags.remove_unused_tags
4
+ end
5
+ end
@@ -0,0 +1,4 @@
1
+ class MigrateTag < ActiveRecord::Base
2
+ self.table_name = 'tags'
3
+ has_many :migrate_taggings, dependent: :destroy, foreign_key: :tag_id, inverse_of: :migrate_tag
4
+ end