additional_tags 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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