From 8c4f714f4c1052817e3bbfcf2d04673374bf0ef7 Mon Sep 17 00:00:00 2001 From: Marius BALTEANU Date: Mon, 6 Aug 2018 19:56:12 +0000 Subject: [PATCH 1/2] Add tags to issues --- Gemfile | 3 + app/models/issue.rb | 27 +++++- app/models/issue_query.rb | 22 +++++ app/models/journal.rb | 8 ++ app/models/query.rb | 14 +++ app/views/issues/_attributes.html.erb | 2 + app/views/issues/show.html.erb | 12 +++ config/locales/en.yml | 1 + ...able_on_migration.acts_as_taggable_on_engine.rb | 36 ++++++++ ...ng_unique_indices.acts_as_taggable_on_engine.rb | 26 ++++++ ...ter_cache_to_tags.acts_as_taggable_on_engine.rb | 20 +++++ ...ng_taggable_index.acts_as_taggable_on_engine.rb | 15 ++++ ...ion_for_tag_names.acts_as_taggable_on_engine.rb | 15 ++++ ...dexes_on_taggings.acts_as_taggable_on_engine.rb | 23 +++++ lib/redmine/export/pdf/issues_pdf_helper.rb | 58 ++++++++----- test/fixtures/taggings.yml | 19 +++++ test/fixtures/tags.yml | 10 +++ test/functional/issues_controller_test.rb | 81 +++++++++++++++++- test/functional/queries_controller_test.rb | 16 +++- test/unit/issue_tags_test.rb | 99 ++++++++++++++++++++++ .../unit/lib/redmine/export/pdf/issues_pdf_test.rb | 12 ++- test/unit/query_test.rb | 63 +++++++++++++- 22 files changed, 552 insertions(+), 30 deletions(-) create mode 100644 db/migrate/20180802191932_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb create mode 100644 db/migrate/20180802191933_add_missing_unique_indices.acts_as_taggable_on_engine.rb create mode 100644 db/migrate/20180802191934_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb create mode 100644 db/migrate/20180802191935_add_missing_taggable_index.acts_as_taggable_on_engine.rb create mode 100644 db/migrate/20180802191936_change_collation_for_tag_names.acts_as_taggable_on_engine.rb create mode 100644 db/migrate/20180802191937_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb create mode 100644 test/fixtures/taggings.yml create mode 100644 test/fixtures/tags.yml create mode 100644 test/unit/issue_tags_test.rb diff --git a/Gemfile b/Gemfile index 69a86c7..a7c489c 100644 --- a/Gemfile +++ b/Gemfile @@ -18,6 +18,9 @@ gem "csv", "~> 1.0.2" if RUBY_VERSION >= "2.3" gem "nokogiri", "~> 1.8.0" gem "i18n", "~> 0.7.0" +# Tags +gem 'acts-as-taggable-on', '~> 6.0' + # Request at least rails-html-sanitizer 1.0.3 because of security advisories gem "rails-html-sanitizer", ">= 1.0.3" diff --git a/app/models/issue.rb b/app/models/issue.rb index 0906771..1dba7d4 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -51,7 +51,7 @@ class Issue < ActiveRecord::Base acts_as_activity_provider :scope => preload(:project, :author, :tracker, :status), :author_key => :author_id - + acts_as_taggable DONE_RATIO_OPTIONS = %w(issue_field issue_status) attr_accessor :deleted_attachment_ids @@ -244,6 +244,7 @@ class Issue < ActiveRecord::Base @total_estimated_hours = nil @last_updated_by = nil @last_notes = nil + @tags_list = nil base_reload(*args) end @@ -459,6 +460,7 @@ class Issue < ActiveRecord::Base 'estimated_hours', 'custom_field_values', 'custom_fields', + 'tag_list', 'lock_version', 'notes', :if => lambda {|issue, user| issue.new_record? || issue.attributes_editable?(user) } @@ -1107,6 +1109,14 @@ class Issue < ActiveRecord::Base end end + def tags_list + if @tags_list + @tags_list + else + tag_list + end + end + # Preloads relations for a collection of issues def self.load_relations(issues) if issues.any? @@ -1161,6 +1171,20 @@ class Issue < ActiveRecord::Base end end + # Preloads tags for a collection of issues + def self.load_issues_tags(issues) + if issues.any? + tags = ActsAsTaggableOn::Tag.joins(:taggings) + .select("#{ActsAsTaggableOn::Tag.table_name}.id", "#{ActsAsTaggableOn::Tag.table_name}.name", + "#{ActsAsTaggableOn::Tagging.table_name}.taggable_id") + .where(:taggings => {:taggable_type => 'Issue', :taggable_id => issues.map(&:id), :context => 'tags'}) + .sort + issues.each do |issue| + issue.instance_variable_set "@tags_list", (tags.select{|t| t.taggable_id == issue.id} || []) + end + end + end + # Returns a scope of the given issues and their descendants def self.self_and_descendants(issues) Issue.joins("JOIN #{Issue.table_name} ancestors" + @@ -1565,7 +1589,6 @@ class Issue < ActiveRecord::Base Tracker.none end end - private def user_tracker_permission?(user, permission) diff --git a/app/models/issue_query.rb b/app/models/issue_query.rb index c342dc4..caed1d4 100644 --- a/app/models/issue_query.rb +++ b/app/models/issue_query.rb @@ -44,6 +44,7 @@ class IssueQuery < Query QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'), QueryColumn.new(:closed_on, :sortable => "#{Issue.table_name}.closed_on", :default_order => 'desc'), QueryColumn.new(:last_updated_by, :sortable => lambda {User.fields_for_order_statement("last_journal_user")}), + QueryColumn.new(:tags_list, :caption => :field_tags), QueryColumn.new(:relations, :caption => :label_related_issues), QueryColumn.new(:attachments, :caption => :label_attachment_plural), QueryColumn.new(:description, :inline => false), @@ -143,6 +144,9 @@ class IssueQuery < Query :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]] end + add_available_filter('tags_list', :type => :list_optional, :name => l(:field_tags), + :values => lambda { tags_values }) + add_available_filter "attachment", :type => :text, :name => l(:label_attachment) @@ -307,6 +311,9 @@ class IssueQuery < Query if has_column?(:last_notes) Issue.load_visible_last_notes(issues) end + if has_column?(:tags_list) + Issue.load_issues_tags(issues) + end issues rescue ::ActiveRecord::StatementInvalid => e raise StatementInvalid.new(e.message) @@ -577,6 +584,21 @@ class IssueQuery < Query "(#{sql})" end + def sql_for_tags_list_field(field, operator, value) + case operator + when '=', '!' + issues = Issue.tagged_with(values_for('tags_list'), any: true) + when '!*' + issues = Issue.tagged_with ActsAsTaggableOn::Tag.all.map(&:to_s), exclude: true + else + issues = Issue.tagged_with ActsAsTaggableOn::Tag.all.map(&:to_s), any: true + end + compare = operator.eql?('!') ? 'NOT IN' : 'IN' + issue_ids = issues.collect {|issue| issue.id }.push(0).join(',') + + "( #{ Issue.table_name }.id #{ compare } (#{ issue_ids }) )" + end + def find_assigned_to_id_filter_values(values) Principal.visible.where(:id => values).map {|p| [p.name, p.id.to_s]} end diff --git a/app/models/journal.rb b/app/models/journal.rb index 77823d0..57ee4cc 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -203,6 +203,7 @@ class Journal < ActiveRecord::Base h[c.custom_field_id] = c.value h end + @tag_list_before_change = journalized.tag_list end self end @@ -272,6 +273,13 @@ class Journal < ActiveRecord::Base end end end + + if @tag_list_before_change + new_tags = journalized.send('tag_list') + if new_tags != @tag_list_before_change + add_attribute_detail('tags', @tag_list_before_change.to_s, new_tags.to_s) + end + end start end diff --git a/app/models/query.rb b/app/models/query.rb index fa5c926..a1ee867 100644 --- a/app/models/query.rb +++ b/app/models/query.rb @@ -579,6 +579,20 @@ class Query < ActiveRecord::Base end end + def tags_values + issues_scope = Issue.visible.select("#{Issue.table_name}.id").joins(:project) + issues_scope.where("#{project.project_condition(Setting.display_subprojects_issues?)}") if project + + result_scope = ActsAsTaggableOn::Tag + .joins(:taggings) + .select('tags.name') + .group('tags.id, tags.name') + .where(taggings: { taggable_type: 'Issue', taggable_id: issues_scope}) + .collect {|t| [t.name, t.name]} + + result_scope + end + # Adds available filters def initialize_available_filters # implemented by sub-classes diff --git a/app/views/issues/_attributes.html.erb b/app/views/issues/_attributes.html.erb index 3a3b5ac..c7d9529 100644 --- a/app/views/issues/_attributes.html.erb +++ b/app/views/issues/_attributes.html.erb @@ -78,6 +78,8 @@ <%= render :partial => 'issues/form_custom_fields' %> <% end %> +

<%= f.text_field :tag_list, :size => 60, :value => @issue.tag_list.to_s %> + <% end %> <% include_calendar_headers_tags %> diff --git a/app/views/issues/show.html.erb b/app/views/issues/show.html.erb index 97a2448..1737e49 100644 --- a/app/views/issues/show.html.erb +++ b/app/views/issues/show.html.erb @@ -72,6 +72,18 @@ end end %> <%= render_half_width_custom_fields_rows(@issue) %> + +<% unless @issue.tag_list.empty? %> +

+
+ <%= l(:field_tags) %>: +
+
+ <%= @issue.tag_list.to_s %> +
+
+<% end %> + <%= call_hook(:view_issues_show_details_bottom, :issue => @issue) %> diff --git a/config/locales/en.yml b/config/locales/en.yml index d8f0943..ee3c585 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -378,6 +378,7 @@ en: field_full_width_layout: Full width layout field_digest: Checksum field_default_assigned_to: Default assignee + field_tags: Tags setting_app_title: Application title setting_welcome_text: Welcome text diff --git a/db/migrate/20180802191932_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb b/db/migrate/20180802191932_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..461eae4 --- /dev/null +++ b/db/migrate/20180802191932_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb @@ -0,0 +1,36 @@ +# This migration comes from acts_as_taggable_on_engine (originally 1) +if ActiveRecord.gem_version >= Gem::Version.new('5.0') + class ActsAsTaggableOnMigration < ActiveRecord::Migration[4.2]; end +else + class ActsAsTaggableOnMigration < ActiveRecord::Migration; end +end +ActsAsTaggableOnMigration.class_eval do + def self.up + create_table :tags do |t| + t.string :name + end + + create_table :taggings do |t| + t.references :tag + + # You should make sure that the column created is + # long enough to store the required class names. + t.references :taggable, polymorphic: true + t.references :tagger, polymorphic: true + + # Limit is created to prevent MySQL error on index + # length for MyISAM table type: http://bit.ly/vgW2Ql + t.string :context, limit: 128 + + t.datetime :created_at + end + + add_index :taggings, :tag_id + add_index :taggings, [:taggable_id, :taggable_type, :context] + end + + def self.down + drop_table :taggings + drop_table :tags + end +end diff --git a/db/migrate/20180802191933_add_missing_unique_indices.acts_as_taggable_on_engine.rb b/db/migrate/20180802191933_add_missing_unique_indices.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..514ac57 --- /dev/null +++ b/db/migrate/20180802191933_add_missing_unique_indices.acts_as_taggable_on_engine.rb @@ -0,0 +1,26 @@ +# This migration comes from acts_as_taggable_on_engine (originally 2) +if ActiveRecord.gem_version >= Gem::Version.new('5.0') + class AddMissingUniqueIndices < ActiveRecord::Migration[4.2]; end +else + class AddMissingUniqueIndices < ActiveRecord::Migration; end +end +AddMissingUniqueIndices.class_eval do + def self.up + add_index :tags, :name, unique: true + + remove_index :taggings, :tag_id if index_exists?(:taggings, :tag_id) + remove_index :taggings, [:taggable_id, :taggable_type, :context] + add_index :taggings, + [:tag_id, :taggable_id, :taggable_type, :context, :tagger_id, :tagger_type], + unique: true, name: 'taggings_idx' + end + + def self.down + remove_index :tags, :name + + remove_index :taggings, name: 'taggings_idx' + + add_index :taggings, :tag_id unless index_exists?(:taggings, :tag_id) + add_index :taggings, [:taggable_id, :taggable_type, :context] + end +end diff --git a/db/migrate/20180802191934_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb b/db/migrate/20180802191934_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..1d9b556 --- /dev/null +++ b/db/migrate/20180802191934_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb @@ -0,0 +1,20 @@ +# This migration comes from acts_as_taggable_on_engine (originally 3) +if ActiveRecord.gem_version >= Gem::Version.new('5.0') + class AddTaggingsCounterCacheToTags < ActiveRecord::Migration[4.2]; end +else + class AddTaggingsCounterCacheToTags < ActiveRecord::Migration; end +end +AddTaggingsCounterCacheToTags.class_eval do + def self.up + add_column :tags, :taggings_count, :integer, default: 0 + + ActsAsTaggableOn::Tag.reset_column_information + ActsAsTaggableOn::Tag.find_each do |tag| + ActsAsTaggableOn::Tag.reset_counters(tag.id, :taggings) + end + end + + def self.down + remove_column :tags, :taggings_count + end +end diff --git a/db/migrate/20180802191935_add_missing_taggable_index.acts_as_taggable_on_engine.rb b/db/migrate/20180802191935_add_missing_taggable_index.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..5f46569 --- /dev/null +++ b/db/migrate/20180802191935_add_missing_taggable_index.acts_as_taggable_on_engine.rb @@ -0,0 +1,15 @@ +# This migration comes from acts_as_taggable_on_engine (originally 4) +if ActiveRecord.gem_version >= Gem::Version.new('5.0') + class AddMissingTaggableIndex < ActiveRecord::Migration[4.2]; end +else + class AddMissingTaggableIndex < ActiveRecord::Migration; end +end +AddMissingTaggableIndex.class_eval do + def self.up + add_index :taggings, [:taggable_id, :taggable_type, :context] + end + + def self.down + remove_index :taggings, [:taggable_id, :taggable_type, :context] + end +end diff --git a/db/migrate/20180802191936_change_collation_for_tag_names.acts_as_taggable_on_engine.rb b/db/migrate/20180802191936_change_collation_for_tag_names.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..f119b16 --- /dev/null +++ b/db/migrate/20180802191936_change_collation_for_tag_names.acts_as_taggable_on_engine.rb @@ -0,0 +1,15 @@ +# This migration comes from acts_as_taggable_on_engine (originally 5) +# This migration is added to circumvent issue #623 and have special characters +# work properly +if ActiveRecord.gem_version >= Gem::Version.new('5.0') + class ChangeCollationForTagNames < ActiveRecord::Migration[4.2]; end +else + class ChangeCollationForTagNames < ActiveRecord::Migration; end +end +ChangeCollationForTagNames.class_eval do + def up + if ActsAsTaggableOn::Utils.using_mysql? + execute("ALTER TABLE tags MODIFY name varchar(255) CHARACTER SET utf8 COLLATE utf8_bin;") + end + end +end diff --git a/db/migrate/20180802191937_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb b/db/migrate/20180802191937_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..94e1f2e --- /dev/null +++ b/db/migrate/20180802191937_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb @@ -0,0 +1,23 @@ +# This migration comes from acts_as_taggable_on_engine (originally 6) +if ActiveRecord.gem_version >= Gem::Version.new('5.0') + class AddMissingIndexesOnTaggings < ActiveRecord::Migration[4.2]; end +else + class AddMissingIndexesOnTaggings < ActiveRecord::Migration; end +end +AddMissingIndexesOnTaggings.class_eval do + def change + add_index :taggings, :tag_id unless index_exists? :taggings, :tag_id + add_index :taggings, :taggable_id unless index_exists? :taggings, :taggable_id + add_index :taggings, :taggable_type unless index_exists? :taggings, :taggable_type + add_index :taggings, :tagger_id unless index_exists? :taggings, :tagger_id + add_index :taggings, :context unless index_exists? :taggings, :context + + unless index_exists? :taggings, [:tagger_id, :tagger_type] + add_index :taggings, [:tagger_id, :tagger_type] + end + + unless index_exists? :taggings, [:taggable_id, :taggable_type, :tagger_id, :context], name: 'taggings_idy' + add_index :taggings, [:taggable_id, :taggable_type, :tagger_id, :context], name: 'taggings_idy' + end + end +end diff --git a/lib/redmine/export/pdf/issues_pdf_helper.rb b/lib/redmine/export/pdf/issues_pdf_helper.rb index 7e2c8a85..4931ea9 100644 --- a/lib/redmine/export/pdf/issues_pdf_helper.rb +++ b/lib/redmine/export/pdf/issues_pdf_helper.rb @@ -45,21 +45,21 @@ module Redmine pdf.SetFontStyle('',8) pdf.RDMMultiCell(190, 5, "#{format_time(issue.created_on)} - #{issue.author}") pdf.ln - + left = [] left << [l(:field_status), issue.status] left << [l(:field_priority), issue.priority] left << [l(:field_assigned_to), issue.assigned_to] unless issue.disabled_core_fields.include?('assigned_to_id') left << [l(:field_category), issue.category] unless issue.disabled_core_fields.include?('category_id') left << [l(:field_fixed_version), issue.fixed_version] unless issue.disabled_core_fields.include?('fixed_version_id') - + right = [] right << [l(:field_start_date), format_date(issue.start_date)] unless issue.disabled_core_fields.include?('start_date') right << [l(:field_due_date), format_date(issue.due_date)] unless issue.disabled_core_fields.include?('due_date') right << [l(:field_done_ratio), "#{issue.done_ratio}%"] unless issue.disabled_core_fields.include?('done_ratio') right << [l(:field_estimated_hours), l_hours(issue.estimated_hours)] unless issue.disabled_core_fields.include?('estimated_hours') right << [l(:label_spent_time), l_hours(issue.total_spent_hours)] if User.current.allowed_to?(:view_time_entries, issue.project) - + rows = left.size > right.size ? left.size : right.size while left.size < rows left << nil @@ -73,7 +73,7 @@ module Redmine custom_field_values.each_with_index do |custom_value, i| (i < half ? left : right) << [custom_value.custom_field.name, show_value(custom_value, false)] end - + if pdf.get_rtl border_first_top = 'RT' border_last_top = 'LT' @@ -85,7 +85,7 @@ module Redmine border_first = 'L' border_last = 'R' end - + rows = left.size > right.size ? left.size : right.size rows.times do |i| heights = [] @@ -100,26 +100,36 @@ module Redmine item = right[i] heights << pdf.get_string_height(60, item ? item.last.to_s : "") height = heights.max - + item = left[i] pdf.SetFontStyle('B',9) pdf.RDMMultiCell(35, height, item ? "#{item.first}:" : "", (i == 0 ? border_first_top : border_first), '', 0, 0) pdf.SetFontStyle('',9) pdf.RDMMultiCell(60, height, item ? item.last.to_s : "", (i == 0 ? border_last_top : border_last), '', 0, 0) - + item = right[i] pdf.SetFontStyle('B',9) pdf.RDMMultiCell(35, height, item ? "#{item.first}:" : "", (i == 0 ? border_first_top : border_first), '', 0, 0) pdf.SetFontStyle('',9) pdf.RDMMultiCell(60, height, item ? item.last.to_s : "", (i == 0 ? border_last_top : border_last), '', 0, 2) - + pdf.set_x(base_x) end - + + tags = issue.tags_list + if tags.any? + pdf.SetFontStyle('B',9) + pdf.RDMCell(35+155, 5, l(:field_tags), "LRT", 1) + pdf.SetFontStyle('',9) + + pdf.SetFontStyle('',9) + pdf.RDMwriteHTMLCell(35+155, 5, '', '', tags.to_s, '', "LRB") + end + pdf.SetFontStyle('B',9) pdf.RDMCell(35+155, 5, l(:field_description), "LRT", 1) pdf.SetFontStyle('',9) - + # Set resize image scale pdf.set_image_scale(1.6) text = textilizable(issue, :description, @@ -157,7 +167,7 @@ module Redmine pdf.ln end end - + relations = issue.relations.select { |r| r.other_issue(issue).visible? } unless relations.empty? truncate_length = (!is_cjk? ? 80 : 60) @@ -185,7 +195,7 @@ module Redmine end pdf.RDMCell(190,5, "", "T") pdf.ln - + if issue.changesets.any? && User.current.allowed_to?(:view_changesets, issue.project) pdf.SetFontStyle('B',9) @@ -205,7 +215,7 @@ module Redmine pdf.ln end end - + if assoc[:journals].present? pdf.SetFontStyle('B',9) pdf.RDMCell(190,5, l(:label_history), "B") @@ -234,7 +244,7 @@ module Redmine pdf.ln end end - + if issue.attachments.any? pdf.SetFontStyle('B',9) pdf.RDMCell(190,5, l(:label_attachment_plural), "B") @@ -261,7 +271,7 @@ module Redmine pdf.footer_date = format_date(User.current.today) pdf.set_auto_page_break(false) pdf.add_page("L") - + # Landscape A4 = 210 x 297 mm page_height = pdf.get_page_height # 210 page_width = pdf.get_page_width # 297 @@ -269,7 +279,7 @@ module Redmine right_margin = pdf.get_original_margins['right'] # 10 bottom_margin = pdf.get_footer_margin row_height = 4 - + # column widths table_width = page_width - right_margin - left_margin col_width = [] @@ -277,13 +287,13 @@ module Redmine col_width = calc_col_width(issues, query, table_width, pdf) table_width = col_width.inject(0, :+) end - + # use full width if the description or last_notes are displayed if table_width > 0 && (query.has_column?(:description) || query.has_column?(:last_notes)) col_width = col_width.map {|w| w * (page_width - right_margin - left_margin) / table_width} table_width = col_width.inject(0, :+) end - + # title pdf.SetFontStyle('B',11) pdf.RDMCell(190, 8, title) @@ -317,10 +327,10 @@ module Redmine end previous_group = group end - + # fetch row values col_values = fetch_row_values(issue, query, level) - + # make new page if it doesn't fit on the current one base_y = pdf.get_y max_height = get_issues_to_pdf_write_cells(pdf, col_values, col_width) @@ -330,11 +340,11 @@ module Redmine render_table_header(pdf, query, col_width, row_height, table_width) base_y = pdf.get_y end - + # write the cells on page issues_to_pdf_write_cells(pdf, col_values, col_width, max_height) pdf.set_y(base_y + max_height) - + if query.has_column?(:description) && issue.description? pdf.set_x(10) pdf.set_auto_page_break(true, bottom_margin) @@ -349,7 +359,7 @@ module Redmine pdf.set_auto_page_break(false) end end - + if issues.size == Setting.issues_export_limit.to_i pdf.SetFontStyle('B',10) pdf.RDMCell(0, row_height, '...') @@ -379,6 +389,8 @@ module Redmine value = " " * level + value when :attachments value = value.to_a.map {|a| a.filename}.join("\n") + when :tags_list + value = value.to_s end if value.is_a?(Date) format_date(value) diff --git a/test/fixtures/taggings.yml b/test/fixtures/taggings.yml new file mode 100644 index 0000000..6cc72a9 --- /dev/null +++ b/test/fixtures/taggings.yml @@ -0,0 +1,19 @@ +--- +tagging_1: + tag_id: 1 + taggable_id: 1 + taggable_type: Issue + context: tags + created_at: <%= 2.days.ago.to_s(:db) %> +tagging_2: + tag_id: 2 + taggable_id: 1 + taggable_type: Issue + context: tags + created_at: <%= 2.days.ago.to_s(:db) %> +tagging_3: + tag_id: 1 + taggable_id: 2 + taggable_type: Issue + context: tags + created_at: <%= 2.days.ago.to_s(:db) %> diff --git a/test/fixtures/tags.yml b/test/fixtures/tags.yml new file mode 100644 index 0000000..82a4ebe --- /dev/null +++ b/test/fixtures/tags.yml @@ -0,0 +1,10 @@ +--- +tag_001: + id: 1 + name: UX +tag_002: + id: 2 + name: Backend +tag_003: + id: 3 + name: API diff --git a/test/functional/issues_controller_test.rb b/test/functional/issues_controller_test.rb index 35c5053..494e7aa 100644 --- a/test/functional/issues_controller_test.rb +++ b/test/functional/issues_controller_test.rb @@ -43,7 +43,9 @@ class IssuesControllerTest < Redmine::ControllerTest :journal_details, :queries, :repositories, - :changesets + :changesets, + :tags, + :taggings include Redmine::I18n @@ -1558,6 +1560,27 @@ class IssuesControllerTest < Redmine::ControllerTest end end + def test_index_with_tags_column + get :index, :params => { + :set_filter => 1, + :project_id => 1, + :c => %w(subject tags_list) + } + + assert_response :success + assert_select 'td.tags_list', :text => 'UX' + assert_select 'td.tags_list', :text => 'UX, Backend' + + get :index, :params => { + :set_filter => 1, + :project_id => 1, + :c => %w(subject tags_list), + :format => 'pdf' + } + assert_response :success + assert_equal 'application/pdf', response.content_type + end + def test_show_by_anonymous get :show, :params => { :id => 1 @@ -2312,6 +2335,28 @@ class IssuesControllerTest < Redmine::ControllerTest assert_select 'a', :text => 'Delete', :count => 0 end + def test_show_should_render_issue_tags_for_issue_with_tags + @request.session[:user_id] = 1 + + get :show, :params => { + :id => 1 + } + + assert_response :success + assert_select 'div.tags .value', :text => 'UX, Backend', :count => 1 + end + + def test_show_should_not_render_issue_tags_for_issue_without_tags + @request.session[:user_id] = 1 + + get :show, :params => { + :id => 14 + } + + assert_response :success + assert_select 'div.tags', 0 + end + def test_get_new @request.session[:user_id] = 2 get :new, :params => { @@ -2338,6 +2383,7 @@ class IssuesControllerTest < Redmine::ControllerTest assert_select 'select[name=?]', 'issue[done_ratio]' assert_select 'input[name=?][value=?]', 'issue[custom_field_values][2]', 'Default string' assert_select 'input[name=?]', 'issue[watcher_user_ids][]' + assert_select 'input[name=?]', 'issue[tag_list]' end # Be sure we don't display inactive IssuePriorities @@ -3291,6 +3337,24 @@ class IssuesControllerTest < Redmine::ControllerTest assert [mail.bcc, mail.cc].flatten.include?(User.find(3).mail) end + def test_post_create_with_tags + @request.session[:user_id] = 2 + + post :create, :params => { + :project_id => 1, + :issue => { + :tracker_id => 1, + :subject => 'This is a new issue with tags', + :description => 'This is the description', + :priority_id => 5, + :tag_list => 'Two, Three' + } + } + + issue = Issue.order('id DESC').first + assert_equal ['Two', 'Three'], issue.tag_list + end + def test_post_create_subissue @request.session[:user_id] = 2 @@ -4644,7 +4708,6 @@ class IssuesControllerTest < Redmine::ControllerTest :project_id => '1', :tracker_id => '2', :priority_id => '6' - } } end @@ -5261,6 +5324,20 @@ class IssuesControllerTest < Redmine::ControllerTest assert_equal 'Original subject', issue.reload.subject end + def test_put_update_issue_tags + @request.session[:user_id] = 1 + + put :update, :params => { + :id => 1, + :issue => { + :tag_list => 'Three' + } + } + assert_response 302 + + assert_equal ['Three'], Issue.find(1).tag_list + end + def test_get_bulk_edit @request.session[:user_id] = 2 get :bulk_edit, :params => { diff --git a/test/functional/queries_controller_test.rb b/test/functional/queries_controller_test.rb index 7133682..a7dc45f 100644 --- a/test/functional/queries_controller_test.rb +++ b/test/functional/queries_controller_test.rb @@ -23,7 +23,7 @@ class QueriesControllerTest < Redmine::ControllerTest :members, :member_roles, :roles, :trackers, :issue_statuses, :issue_categories, :enumerations, :versions, :issues, :custom_fields, :custom_values, - :queries + :queries, :tags, :taggings def setup User.current = nil @@ -732,4 +732,18 @@ class QueriesControllerTest < Redmine::ControllerTest assert_include ["Dave Lopper", "3", "active"], json assert_include ["Dave2 Lopper2", "5", "locked"], json end + + def test_filter_with_tags_should_return_filter_values + @request.session[:user_id] = 2 + get :filter, :params => { + :project_id => 1, + :type => 'IssueQuery', + :name => 'tags_list' + } + + assert_response :success + assert_equal 'application/json', response.content_type + json = ActiveSupport::JSON.decode(response.body) + assert_equal [["UX","UX"],["Backend","Backend"]], json + end end diff --git a/test/unit/issue_tags_test.rb b/test/unit/issue_tags_test.rb new file mode 100644 index 0000000..be4385c --- /dev/null +++ b/test/unit/issue_tags_test.rb @@ -0,0 +1,99 @@ +# Redmine - project management software +# Copyright (C) 2006-2017 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require File.expand_path('../../test_helper', __FILE__) + +class IssueTagsTest < ActiveSupport::TestCase + fixtures :projects, :users, :email_addresses, :user_preferences, :members, :member_roles, :roles, + :groups_users, + :trackers, :projects_trackers, + :enabled_modules, + :issue_statuses, + :issues, :journals, :journal_details, + :tags, :taggings + + include Redmine::I18n + + def setup + set_language_if_valid 'en' + end + + def teardown + User.current = nil + end + + def test_issue_tag_list_should_return_an_array_of_tags + assert_equal ['UX', 'Backend'], Issue.find(1).tag_list + end + + def test_issue_tag_list_to_s_should_return_a_string_of_tags_delimited_by_comma + assert_equal 'UX, Backend', Issue.find(1).tag_list.to_s + end + + def test_add_issue_tags + issue = Issue.find(2) + issue.tag_list = "One, Two" + + assert issue.save! + + issue.reload + + assert_equal ['One', 'Two'], issue.tag_list + end + + def test_clear_issue_tags + issue = Issue.find(1) + issue.tag_list = '' + + assert issue.save! + assert_equal [], issue.tag_list + end + + def test_update_issue_tags_should_journalize_changes + issue = Issue.find(1) + issue.init_journal User.find(1) + issue.tag_list = "UX, API" + + assert_difference 'Journal.count', 1 do + assert_difference 'JournalDetail.count', 1 do + issue.save! + end + end + issue.reload + + assert_equal ['UX', 'API'], issue.tag_list + + detail = JournalDetail.order('id DESC').first + assert_equal issue, detail.journal.journalized + assert_equal 'attr', detail.property + assert_equal 'tags', detail.prop_key + assert_equal 'UX, Backend', detail.old_value + assert_equal 'UX, API', detail.value + end + + def test_update_issue_tags_should_not_journalize_changes_if_tags_are_not_changed + issue = Issue.find(1) + issue.init_journal User.find(1) + issue.tag_list = "UX, Backend" + + assert_difference 'Journal.count', 0 do + assert_difference 'JournalDetail.count', 0 do + issue.save! + end + end + end +end diff --git a/test/unit/lib/redmine/export/pdf/issues_pdf_test.rb b/test/unit/lib/redmine/export/pdf/issues_pdf_test.rb index c7b3ae9..c23f8bd 100644 --- a/test/unit/lib/redmine/export/pdf/issues_pdf_test.rb +++ b/test/unit/lib/redmine/export/pdf/issues_pdf_test.rb @@ -19,7 +19,8 @@ require File.expand_path('../../../../../../test_helper', __FILE__) class IssuesPdfHelperTest < ActiveSupport::TestCase fixtures :users, :projects, :roles, :members, :member_roles, - :enabled_modules, :issues, :trackers, :enumerations + :enabled_modules, :issues, :trackers, :enumerations, + :tags, :taggings include Redmine::Export::PDF::IssuesPdfHelper @@ -32,4 +33,13 @@ class IssuesPdfHelperTest < ActiveSupport::TestCase results = fetch_row_values(issue, query, 0) assert_equal ["2", "Add ingredients categories", "4.34"], results end + + def test_fetch_row_values_should_return_issue_tags_as_string + query = IssueQuery.new(:project => Project.find(1), :name => '_') + query.column_names = [:subject, :tags_list] + + results = fetch_row_values(Issue.find(1), query, 0) + + assert_equal ["1", "Cannot print recipes", "UX, Backend"], results + end end diff --git a/test/unit/query_test.rb b/test/unit/query_test.rb index aa29b24..191c934 100644 --- a/test/unit/query_test.rb +++ b/test/unit/query_test.rb @@ -30,7 +30,8 @@ class QueryTest < ActiveSupport::TestCase :projects_trackers, :custom_fields_trackers, :workflows, :journals, - :attachments + :attachments, + :tags, :taggings INTEGER_KLASS = RUBY_VERSION >= "2.4" ? Integer : Fixnum @@ -821,6 +822,59 @@ class QueryTest < ActiveSupport::TestCase end end + def test_filter_by_tags_with_operator_is + query = IssueQuery.new(:name => '_') + filter_name = "tags_list" + assert_include filter_name, query.available_filters.keys + + query.filters = {filter_name => {:operator => '=', :values => ['UX']}} + assert_equal [1, 2], find_issues_with_query(query).map(&:id).sort + + # Should return issue tagged with any of the values + query.filters = {filter_name => {:operator => '=', :values => ['UX, Backend']}} + assert_equal [1, 2], find_issues_with_query(query).map(&:id).sort + end + + def test_filter_by_tags_with_operator_is_not + query = IssueQuery.new(:name => '_') + filter_name = "tags_list" + assert_include filter_name, query.available_filters.keys + + query.filters = {filter_name => {:operator => '!', :values => ['Backend']}} + issues = find_issues_with_query(query).map(&:id).sort + + # Issue tagged with Backend should not be returned + assert_not_include 1, issues + assert_include 2, issues + # Untagged issues should be returned + assert_include 5, issues + end + + def test_filter_by_tags_with_operator_none + query = IssueQuery.new(:name => '_') + filter_name = "tags_list" + assert_include filter_name, query.available_filters.keys + + query.filters = {filter_name => {:operator => '!*', :values => ['']}} + issues = find_issues_with_query(query).map(&:id).sort + + # Tagged issues should not be returned + assert_not_include 1, issues + assert_not_include 2, issues + + # Untagged issues should be returned + assert_include 5, issues + end + + def test_filter_by_tags_with_operator_any + query = IssueQuery.new(:name => '_') + filter_name = "tags_list" + assert_include filter_name, query.available_filters.keys + + query.filters = {filter_name => {:operator => '*', :values => ['']}} + assert_equal [1, 2], find_issues_with_query(query).map(&:id).sort + end + def test_user_custom_field_filtered_on_me User.current = User.find(2) cf = IssueCustomField.create!(:field_format => 'user', :is_for_all => true, :is_filter => true, :name => 'User custom field', :tracker_ids => [1]) @@ -1400,6 +1454,13 @@ class QueryTest < ActiveSupport::TestCase assert_not_nil issues.first.instance_variable_get("@last_notes") end + def test_query_should_preload_tags + q = IssueQuery.new(:name => '_', :column_names => [:subject, :tags_list]) + assert q.has_column?(:tags_list) + issues = q.issues + assert_not_nil issues.first.instance_variable_get("@tags_list") + end + def test_groupable_columns_should_include_custom_fields q = IssueQuery.new column = q.groupable_columns.detect {|c| c.name == :cf_1} -- 2.1.4