Feature #1448 » 0001-Add-tags-to-issues.patch
| Gemfile | ||
|---|---|---|
| 18 | 18 | gem "nokogiri", "~> 1.8.0" | 
| 19 | 19 | gem "i18n", "~> 0.7.0" | 
| 20 | 20 | |
| 21 | # Tags | |
| 22 | gem 'acts-as-taggable-on', '~> 6.0' | |
| 23 | ||
| 21 | 24 | # Request at least rails-html-sanitizer 1.0.3 because of security advisories | 
| 22 | 25 | gem "rails-html-sanitizer", ">= 1.0.3" | 
| 23 | 26 | |
| app/models/issue.rb | ||
|---|---|---|
| 51 | 51 | |
| 52 | 52 | acts_as_activity_provider :scope => preload(:project, :author, :tracker, :status), | 
| 53 | 53 | :author_key => :author_id | 
| 54 | ||
| 54 | acts_as_taggable | |
| 55 | 55 | DONE_RATIO_OPTIONS = %w(issue_field issue_status) | 
| 56 | 56 | |
| 57 | 57 | attr_accessor :deleted_attachment_ids | 
| ... | ... | |
| 244 | 244 | @total_estimated_hours = nil | 
| 245 | 245 | @last_updated_by = nil | 
| 246 | 246 | @last_notes = nil | 
| 247 | @tags_list = nil | |
| 247 | 248 | base_reload(*args) | 
| 248 | 249 | end | 
| 249 | 250 | |
| ... | ... | |
| 459 | 460 | 'estimated_hours', | 
| 460 | 461 | 'custom_field_values', | 
| 461 | 462 | 'custom_fields', | 
| 463 | 'tag_list', | |
| 462 | 464 | 'lock_version', | 
| 463 | 465 | 'notes', | 
| 464 | 466 |     :if => lambda {|issue, user| issue.new_record? || issue.attributes_editable?(user) } | 
| ... | ... | |
| 1107 | 1109 | end | 
| 1108 | 1110 | end | 
| 1109 | 1111 | |
| 1112 | def tags_list | |
| 1113 | if @tags_list | |
| 1114 | @tags_list | |
| 1115 | else | |
| 1116 | tag_list | |
| 1117 | end | |
| 1118 | end | |
| 1119 | ||
| 1110 | 1120 | # Preloads relations for a collection of issues | 
| 1111 | 1121 | def self.load_relations(issues) | 
| 1112 | 1122 | if issues.any? | 
| ... | ... | |
| 1161 | 1171 | end | 
| 1162 | 1172 | end | 
| 1163 | 1173 | |
| 1174 | # Preloads tags for a collection of issues | |
| 1175 | def self.load_issues_tags(issues) | |
| 1176 | if issues.any? | |
| 1177 | tags = ActsAsTaggableOn::Tag.joins(:taggings) | |
| 1178 |       .select("#{ActsAsTaggableOn::Tag.table_name}.id", "#{ActsAsTaggableOn::Tag.table_name}.name",  | |
| 1179 |         "#{ActsAsTaggableOn::Tagging.table_name}.taggable_id") | |
| 1180 |       .where(:taggings => {:taggable_type => 'Issue', :taggable_id => issues.map(&:id), :context => 'tags'}) | |
| 1181 | .sort | |
| 1182 | issues.each do |issue| | |
| 1183 |         issue.instance_variable_set "@tags_list", (tags.select{|t| t.taggable_id == issue.id} || []) | |
| 1184 | end | |
| 1185 | end | |
| 1186 | end | |
| 1187 | ||
| 1164 | 1188 | # Returns a scope of the given issues and their descendants | 
| 1165 | 1189 | def self.self_and_descendants(issues) | 
| 1166 | 1190 |     Issue.joins("JOIN #{Issue.table_name} ancestors" + | 
| ... | ... | |
| 1565 | 1589 | Tracker.none | 
| 1566 | 1590 | end | 
| 1567 | 1591 | end | 
| 1568 | ||
| 1569 | 1592 | private | 
| 1570 | 1593 | |
| 1571 | 1594 | def user_tracker_permission?(user, permission) | 
| app/models/issue_query.rb | ||
|---|---|---|
| 44 | 44 |     QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'), | 
| 45 | 45 |     QueryColumn.new(:closed_on, :sortable => "#{Issue.table_name}.closed_on", :default_order => 'desc'), | 
| 46 | 46 |     QueryColumn.new(:last_updated_by, :sortable => lambda {User.fields_for_order_statement("last_journal_user")}), | 
| 47 | QueryColumn.new(:tags_list, :caption => :field_tags), | |
| 47 | 48 | QueryColumn.new(:relations, :caption => :label_related_issues), | 
| 48 | 49 | QueryColumn.new(:attachments, :caption => :label_attachment_plural), | 
| 49 | 50 | QueryColumn.new(:description, :inline => false), | 
| ... | ... | |
| 143 | 144 | :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]] | 
| 144 | 145 | end | 
| 145 | 146 | |
| 147 |     add_available_filter('tags_list', :type => :list_optional, :name => l(:field_tags), | |
| 148 |       :values => lambda { tags_values }) | |
| 149 | ||
| 146 | 150 | add_available_filter "attachment", | 
| 147 | 151 | :type => :text, :name => l(:label_attachment) | 
| 148 | 152 | |
| ... | ... | |
| 307 | 311 | if has_column?(:last_notes) | 
| 308 | 312 | Issue.load_visible_last_notes(issues) | 
| 309 | 313 | end | 
| 314 | if has_column?(:tags_list) | |
| 315 | Issue.load_issues_tags(issues) | |
| 316 | end | |
| 310 | 317 | issues | 
| 311 | 318 | rescue ::ActiveRecord::StatementInvalid => e | 
| 312 | 319 | raise StatementInvalid.new(e.message) | 
| ... | ... | |
| 577 | 584 |     "(#{sql})" | 
| 578 | 585 | end | 
| 579 | 586 | |
| 587 | def sql_for_tags_list_field(field, operator, value) | |
| 588 | case operator | |
| 589 | when '=', '!' | |
| 590 |         issues = Issue.tagged_with(values_for('tags_list'), any: true) | |
| 591 | when '!*' | |
| 592 | issues = Issue.tagged_with ActsAsTaggableOn::Tag.all.map(&:to_s), exclude: true | |
| 593 | else | |
| 594 | issues = Issue.tagged_with ActsAsTaggableOn::Tag.all.map(&:to_s), any: true | |
| 595 | end | |
| 596 |       compare = operator.eql?('!') ? 'NOT IN' : 'IN' | |
| 597 |       issue_ids = issues.collect {|issue| issue.id }.push(0).join(',') | |
| 598 | ||
| 599 |       "( #{ Issue.table_name }.id #{ compare } (#{ issue_ids }) )" | |
| 600 | end | |
| 601 | ||
| 580 | 602 | def find_assigned_to_id_filter_values(values) | 
| 581 | 603 |     Principal.visible.where(:id => values).map {|p| [p.name, p.id.to_s]} | 
| 582 | 604 | end | 
| app/models/journal.rb | ||
|---|---|---|
| 203 | 203 | h[c.custom_field_id] = c.value | 
| 204 | 204 | h | 
| 205 | 205 | end | 
| 206 | @tag_list_before_change = journalized.tag_list | |
| 206 | 207 | end | 
| 207 | 208 | self | 
| 208 | 209 | end | 
| ... | ... | |
| 272 | 273 | end | 
| 273 | 274 | end | 
| 274 | 275 | end | 
| 276 | ||
| 277 | if @tag_list_before_change | |
| 278 |       new_tags = journalized.send('tag_list') | |
| 279 | if new_tags != @tag_list_before_change | |
| 280 |         add_attribute_detail('tags', @tag_list_before_change.to_s, new_tags.to_s) | |
| 281 | end | |
| 282 | end | |
| 275 | 283 | start | 
| 276 | 284 | end | 
| 277 | 285 | |
| app/models/query.rb | ||
|---|---|---|
| 579 | 579 | end | 
| 580 | 580 | end | 
| 581 | 581 | |
| 582 | def tags_values | |
| 583 |     issues_scope = Issue.visible.select("#{Issue.table_name}.id").joins(:project) | |
| 584 |     issues_scope.where("#{project.project_condition(Setting.display_subprojects_issues?)}") if project | |
| 585 | ||
| 586 | result_scope = ActsAsTaggableOn::Tag | |
| 587 | .joins(:taggings) | |
| 588 |       .select('tags.name') | |
| 589 |       .group('tags.id, tags.name') | |
| 590 |       .where(taggings: { taggable_type: 'Issue', taggable_id: issues_scope}) | |
| 591 |       .collect {|t| [t.name, t.name]} | |
| 592 | ||
| 593 | result_scope | |
| 594 | end | |
| 595 | ||
| 582 | 596 | # Adds available filters | 
| 583 | 597 | def initialize_available_filters | 
| 584 | 598 | # implemented by sub-classes | 
| app/views/issues/_attributes.html.erb | ||
|---|---|---|
| 78 | 78 | <%= render :partial => 'issues/form_custom_fields' %> | 
| 79 | 79 | <% end %> | 
| 80 | 80 | |
| 81 | <p><%= f.text_field :tag_list, :size => 60, :value => @issue.tag_list.to_s %> | |
| 82 | ||
| 81 | 83 | <% end %> | 
| 82 | 84 | |
| 83 | 85 | <% include_calendar_headers_tags %> | 
| app/views/issues/show.html.erb | ||
|---|---|---|
| 72 | 72 | end | 
| 73 | 73 | end %> | 
| 74 | 74 | <%= render_half_width_custom_fields_rows(@issue) %> | 
| 75 | ||
| 76 | <% unless @issue.tag_list.empty? %> | |
| 77 | <div class="tags attribute"> | |
| 78 | <div class="label"> | |
| 79 | <span><%= l(:field_tags) %>:</span> | |
| 80 | </div> | |
| 81 | <div class="value"> | |
| 82 | <%= @issue.tag_list.to_s %> | |
| 83 | </div> | |
| 84 | </div> | |
| 85 | <% end %> | |
| 86 | ||
| 75 | 87 | <%= call_hook(:view_issues_show_details_bottom, :issue => @issue) %> | 
| 76 | 88 | </div> | 
| 77 | 89 | |
| config/locales/en.yml | ||
|---|---|---|
| 378 | 378 | field_full_width_layout: Full width layout | 
| 379 | 379 | field_digest: Checksum | 
| 380 | 380 | field_default_assigned_to: Default assignee | 
| 381 | field_tags: Tags | |
| 381 | 382 | |
| 382 | 383 | setting_app_title: Application title | 
| 383 | 384 | setting_welcome_text: Welcome text | 
| db/migrate/20180802191932_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb | ||
|---|---|---|
| 1 | # This migration comes from acts_as_taggable_on_engine (originally 1) | |
| 2 | if ActiveRecord.gem_version >= Gem::Version.new('5.0') | |
| 3 | class ActsAsTaggableOnMigration < ActiveRecord::Migration[4.2]; end | |
| 4 | else | |
| 5 | class ActsAsTaggableOnMigration < ActiveRecord::Migration; end | |
| 6 | end | |
| 7 | ActsAsTaggableOnMigration.class_eval do | |
| 8 | def self.up | |
| 9 | create_table :tags do |t| | |
| 10 | t.string :name | |
| 11 | end | |
| 12 | ||
| 13 | create_table :taggings do |t| | |
| 14 | t.references :tag | |
| 15 | ||
| 16 | # You should make sure that the column created is | |
| 17 | # long enough to store the required class names. | |
| 18 | t.references :taggable, polymorphic: true | |
| 19 | t.references :tagger, polymorphic: true | |
| 20 | ||
| 21 | # Limit is created to prevent MySQL error on index | |
| 22 | # length for MyISAM table type: http://bit.ly/vgW2Ql | |
| 23 | t.string :context, limit: 128 | |
| 24 | ||
| 25 | t.datetime :created_at | |
| 26 | end | |
| 27 | ||
| 28 | add_index :taggings, :tag_id | |
| 29 | add_index :taggings, [:taggable_id, :taggable_type, :context] | |
| 30 | end | |
| 31 | ||
| 32 | def self.down | |
| 33 | drop_table :taggings | |
| 34 | drop_table :tags | |
| 35 | end | |
| 36 | end | |
| db/migrate/20180802191933_add_missing_unique_indices.acts_as_taggable_on_engine.rb | ||
|---|---|---|
| 1 | # This migration comes from acts_as_taggable_on_engine (originally 2) | |
| 2 | if ActiveRecord.gem_version >= Gem::Version.new('5.0') | |
| 3 | class AddMissingUniqueIndices < ActiveRecord::Migration[4.2]; end | |
| 4 | else | |
| 5 | class AddMissingUniqueIndices < ActiveRecord::Migration; end | |
| 6 | end | |
| 7 | AddMissingUniqueIndices.class_eval do | |
| 8 | def self.up | |
| 9 | add_index :tags, :name, unique: true | |
| 10 | ||
| 11 | remove_index :taggings, :tag_id if index_exists?(:taggings, :tag_id) | |
| 12 | remove_index :taggings, [:taggable_id, :taggable_type, :context] | |
| 13 | add_index :taggings, | |
| 14 | [:tag_id, :taggable_id, :taggable_type, :context, :tagger_id, :tagger_type], | |
| 15 | unique: true, name: 'taggings_idx' | |
| 16 | end | |
| 17 | ||
| 18 | def self.down | |
| 19 | remove_index :tags, :name | |
| 20 | ||
| 21 | remove_index :taggings, name: 'taggings_idx' | |
| 22 | ||
| 23 | add_index :taggings, :tag_id unless index_exists?(:taggings, :tag_id) | |
| 24 | add_index :taggings, [:taggable_id, :taggable_type, :context] | |
| 25 | end | |
| 26 | end | |
| db/migrate/20180802191934_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb | ||
|---|---|---|
| 1 | # This migration comes from acts_as_taggable_on_engine (originally 3) | |
| 2 | if ActiveRecord.gem_version >= Gem::Version.new('5.0') | |
| 3 | class AddTaggingsCounterCacheToTags < ActiveRecord::Migration[4.2]; end | |
| 4 | else | |
| 5 | class AddTaggingsCounterCacheToTags < ActiveRecord::Migration; end | |
| 6 | end | |
| 7 | AddTaggingsCounterCacheToTags.class_eval do | |
| 8 | def self.up | |
| 9 | add_column :tags, :taggings_count, :integer, default: 0 | |
| 10 | ||
| 11 | ActsAsTaggableOn::Tag.reset_column_information | |
| 12 | ActsAsTaggableOn::Tag.find_each do |tag| | |
| 13 | ActsAsTaggableOn::Tag.reset_counters(tag.id, :taggings) | |
| 14 | end | |
| 15 | end | |
| 16 | ||
| 17 | def self.down | |
| 18 | remove_column :tags, :taggings_count | |
| 19 | end | |
| 20 | end | |
| db/migrate/20180802191935_add_missing_taggable_index.acts_as_taggable_on_engine.rb | ||
|---|---|---|
| 1 | # This migration comes from acts_as_taggable_on_engine (originally 4) | |
| 2 | if ActiveRecord.gem_version >= Gem::Version.new('5.0') | |
| 3 | class AddMissingTaggableIndex < ActiveRecord::Migration[4.2]; end | |
| 4 | else | |
| 5 | class AddMissingTaggableIndex < ActiveRecord::Migration; end | |
| 6 | end | |
| 7 | AddMissingTaggableIndex.class_eval do | |
| 8 | def self.up | |
| 9 | add_index :taggings, [:taggable_id, :taggable_type, :context] | |
| 10 | end | |
| 11 | ||
| 12 | def self.down | |
| 13 | remove_index :taggings, [:taggable_id, :taggable_type, :context] | |
| 14 | end | |
| 15 | end | |
| db/migrate/20180802191936_change_collation_for_tag_names.acts_as_taggable_on_engine.rb | ||
|---|---|---|
| 1 | # This migration comes from acts_as_taggable_on_engine (originally 5) | |
| 2 | # This migration is added to circumvent issue #623 and have special characters | |
| 3 | # work properly | |
| 4 | if ActiveRecord.gem_version >= Gem::Version.new('5.0') | |
| 5 | class ChangeCollationForTagNames < ActiveRecord::Migration[4.2]; end | |
| 6 | else | |
| 7 | class ChangeCollationForTagNames < ActiveRecord::Migration; end | |
| 8 | end | |
| 9 | ChangeCollationForTagNames.class_eval do | |
| 10 | def up | |
| 11 | if ActsAsTaggableOn::Utils.using_mysql? | |
| 12 |       execute("ALTER TABLE tags MODIFY name varchar(255) CHARACTER SET utf8 COLLATE utf8_bin;") | |
| 13 | end | |
| 14 | end | |
| 15 | end | |
| db/migrate/20180802191937_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb | ||
|---|---|---|
| 1 | # This migration comes from acts_as_taggable_on_engine (originally 6) | |
| 2 | if ActiveRecord.gem_version >= Gem::Version.new('5.0') | |
| 3 | class AddMissingIndexesOnTaggings < ActiveRecord::Migration[4.2]; end | |
| 4 | else | |
| 5 | class AddMissingIndexesOnTaggings < ActiveRecord::Migration; end | |
| 6 | end | |
| 7 | AddMissingIndexesOnTaggings.class_eval do | |
| 8 | def change | |
| 9 | add_index :taggings, :tag_id unless index_exists? :taggings, :tag_id | |
| 10 | add_index :taggings, :taggable_id unless index_exists? :taggings, :taggable_id | |
| 11 | add_index :taggings, :taggable_type unless index_exists? :taggings, :taggable_type | |
| 12 | add_index :taggings, :tagger_id unless index_exists? :taggings, :tagger_id | |
| 13 | add_index :taggings, :context unless index_exists? :taggings, :context | |
| 14 | ||
| 15 | unless index_exists? :taggings, [:tagger_id, :tagger_type] | |
| 16 | add_index :taggings, [:tagger_id, :tagger_type] | |
| 17 | end | |
| 18 | ||
| 19 | unless index_exists? :taggings, [:taggable_id, :taggable_type, :tagger_id, :context], name: 'taggings_idy' | |
| 20 | add_index :taggings, [:taggable_id, :taggable_type, :tagger_id, :context], name: 'taggings_idy' | |
| 21 | end | |
| 22 | end | |
| 23 | end | |
| lib/redmine/export/pdf/issues_pdf_helper.rb | ||
|---|---|---|
| 45 | 45 |           pdf.SetFontStyle('',8) | 
| 46 | 46 |           pdf.RDMMultiCell(190, 5, "#{format_time(issue.created_on)} - #{issue.author}") | 
| 47 | 47 | pdf.ln | 
| 48 |  | |
| 48 | ||
| 49 | 49 | left = [] | 
| 50 | 50 | left << [l(:field_status), issue.status] | 
| 51 | 51 | left << [l(:field_priority), issue.priority] | 
| 52 | 52 |           left << [l(:field_assigned_to), issue.assigned_to] unless issue.disabled_core_fields.include?('assigned_to_id') | 
| 53 | 53 |           left << [l(:field_category), issue.category] unless issue.disabled_core_fields.include?('category_id') | 
| 54 | 54 |           left << [l(:field_fixed_version), issue.fixed_version] unless issue.disabled_core_fields.include?('fixed_version_id') | 
| 55 |  | |
| 55 | ||
| 56 | 56 | right = [] | 
| 57 | 57 |           right << [l(:field_start_date), format_date(issue.start_date)] unless issue.disabled_core_fields.include?('start_date') | 
| 58 | 58 |           right << [l(:field_due_date), format_date(issue.due_date)] unless issue.disabled_core_fields.include?('due_date') | 
| 59 | 59 |           right << [l(:field_done_ratio), "#{issue.done_ratio}%"] unless issue.disabled_core_fields.include?('done_ratio') | 
| 60 | 60 |           right << [l(:field_estimated_hours), l_hours(issue.estimated_hours)] unless issue.disabled_core_fields.include?('estimated_hours') | 
| 61 | 61 | right << [l(:label_spent_time), l_hours(issue.total_spent_hours)] if User.current.allowed_to?(:view_time_entries, issue.project) | 
| 62 |  | |
| 62 | ||
| 63 | 63 | rows = left.size > right.size ? left.size : right.size | 
| 64 | 64 | while left.size < rows | 
| 65 | 65 | left << nil | 
| ... | ... | |
| 73 | 73 | custom_field_values.each_with_index do |custom_value, i| | 
| 74 | 74 | (i < half ? left : right) << [custom_value.custom_field.name, show_value(custom_value, false)] | 
| 75 | 75 | end | 
| 76 |  | |
| 76 | ||
| 77 | 77 | if pdf.get_rtl | 
| 78 | 78 | border_first_top = 'RT' | 
| 79 | 79 | border_last_top = 'LT' | 
| ... | ... | |
| 85 | 85 | border_first = 'L' | 
| 86 | 86 | border_last = 'R' | 
| 87 | 87 | end | 
| 88 |  | |
| 88 | ||
| 89 | 89 | rows = left.size > right.size ? left.size : right.size | 
| 90 | 90 | rows.times do |i| | 
| 91 | 91 | heights = [] | 
| ... | ... | |
| 100 | 100 | item = right[i] | 
| 101 | 101 | heights << pdf.get_string_height(60, item ? item.last.to_s : "") | 
| 102 | 102 | height = heights.max | 
| 103 |  | |
| 103 | ||
| 104 | 104 | item = left[i] | 
| 105 | 105 |             pdf.SetFontStyle('B',9) | 
| 106 | 106 |             pdf.RDMMultiCell(35, height, item ? "#{item.first}:" : "", (i == 0 ? border_first_top : border_first), '', 0, 0) | 
| 107 | 107 |             pdf.SetFontStyle('',9) | 
| 108 | 108 | pdf.RDMMultiCell(60, height, item ? item.last.to_s : "", (i == 0 ? border_last_top : border_last), '', 0, 0) | 
| 109 |  | |
| 109 | ||
| 110 | 110 | item = right[i] | 
| 111 | 111 |             pdf.SetFontStyle('B',9) | 
| 112 | 112 |             pdf.RDMMultiCell(35, height, item ? "#{item.first}:" : "",  (i == 0 ? border_first_top : border_first), '', 0, 0) | 
| 113 | 113 |             pdf.SetFontStyle('',9) | 
| 114 | 114 | pdf.RDMMultiCell(60, height, item ? item.last.to_s : "", (i == 0 ? border_last_top : border_last), '', 0, 2) | 
| 115 |  | |
| 115 | ||
| 116 | 116 | pdf.set_x(base_x) | 
| 117 | 117 | end | 
| 118 |  | |
| 118 | ||
| 119 | tags = issue.tags_list | |
| 120 | if tags.any? | |
| 121 |             pdf.SetFontStyle('B',9) | |
| 122 | pdf.RDMCell(35+155, 5, l(:field_tags), "LRT", 1) | |
| 123 |             pdf.SetFontStyle('',9) | |
| 124 | ||
| 125 |             pdf.SetFontStyle('',9) | |
| 126 | pdf.RDMwriteHTMLCell(35+155, 5, '', '', tags.to_s, '', "LRB") | |
| 127 | end | |
| 128 | ||
| 119 | 129 |           pdf.SetFontStyle('B',9) | 
| 120 | 130 | pdf.RDMCell(35+155, 5, l(:field_description), "LRT", 1) | 
| 121 | 131 |           pdf.SetFontStyle('',9) | 
| 122 |  | |
| 132 | ||
| 123 | 133 | # Set resize image scale | 
| 124 | 134 | pdf.set_image_scale(1.6) | 
| 125 | 135 | text = textilizable(issue, :description, | 
| ... | ... | |
| 157 | 167 | pdf.ln | 
| 158 | 168 | end | 
| 159 | 169 | end | 
| 160 |  | |
| 170 | ||
| 161 | 171 |           relations = issue.relations.select { |r| r.other_issue(issue).visible? } | 
| 162 | 172 | unless relations.empty? | 
| 163 | 173 | truncate_length = (!is_cjk? ? 80 : 60) | 
| ... | ... | |
| 185 | 195 | end | 
| 186 | 196 | pdf.RDMCell(190,5, "", "T") | 
| 187 | 197 | pdf.ln | 
| 188 |  | |
| 198 | ||
| 189 | 199 | if issue.changesets.any? && | 
| 190 | 200 | User.current.allowed_to?(:view_changesets, issue.project) | 
| 191 | 201 |             pdf.SetFontStyle('B',9) | 
| ... | ... | |
| 205 | 215 | pdf.ln | 
| 206 | 216 | end | 
| 207 | 217 | end | 
| 208 |  | |
| 218 | ||
| 209 | 219 | if assoc[:journals].present? | 
| 210 | 220 |             pdf.SetFontStyle('B',9) | 
| 211 | 221 | pdf.RDMCell(190,5, l(:label_history), "B") | 
| ... | ... | |
| 234 | 244 | pdf.ln | 
| 235 | 245 | end | 
| 236 | 246 | end | 
| 237 |  | |
| 247 | ||
| 238 | 248 | if issue.attachments.any? | 
| 239 | 249 |             pdf.SetFontStyle('B',9) | 
| 240 | 250 | pdf.RDMCell(190,5, l(:label_attachment_plural), "B") | 
| ... | ... | |
| 261 | 271 | pdf.footer_date = format_date(User.current.today) | 
| 262 | 272 | pdf.set_auto_page_break(false) | 
| 263 | 273 |           pdf.add_page("L") | 
| 264 |  | |
| 274 | ||
| 265 | 275 | # Landscape A4 = 210 x 297 mm | 
| 266 | 276 | page_height = pdf.get_page_height # 210 | 
| 267 | 277 | page_width = pdf.get_page_width # 297 | 
| ... | ... | |
| 269 | 279 | right_margin = pdf.get_original_margins['right'] # 10 | 
| 270 | 280 | bottom_margin = pdf.get_footer_margin | 
| 271 | 281 | row_height = 4 | 
| 272 |  | |
| 282 | ||
| 273 | 283 | # column widths | 
| 274 | 284 | table_width = page_width - right_margin - left_margin | 
| 275 | 285 | col_width = [] | 
| ... | ... | |
| 277 | 287 | col_width = calc_col_width(issues, query, table_width, pdf) | 
| 278 | 288 | table_width = col_width.inject(0, :+) | 
| 279 | 289 | end | 
| 280 |  | |
| 290 | ||
| 281 | 291 | # use full width if the description or last_notes are displayed | 
| 282 | 292 | if table_width > 0 && (query.has_column?(:description) || query.has_column?(:last_notes)) | 
| 283 | 293 |             col_width = col_width.map {|w| w * (page_width - right_margin - left_margin) / table_width} | 
| 284 | 294 | table_width = col_width.inject(0, :+) | 
| 285 | 295 | end | 
| 286 |  | |
| 296 | ||
| 287 | 297 | # title | 
| 288 | 298 |           pdf.SetFontStyle('B',11) | 
| 289 | 299 | pdf.RDMCell(190, 8, title) | 
| ... | ... | |
| 317 | 327 | end | 
| 318 | 328 | previous_group = group | 
| 319 | 329 | end | 
| 320 |  | |
| 330 | ||
| 321 | 331 | # fetch row values | 
| 322 | 332 | col_values = fetch_row_values(issue, query, level) | 
| 323 |  | |
| 333 | ||
| 324 | 334 | # make new page if it doesn't fit on the current one | 
| 325 | 335 | base_y = pdf.get_y | 
| 326 | 336 | max_height = get_issues_to_pdf_write_cells(pdf, col_values, col_width) | 
| ... | ... | |
| 330 | 340 | render_table_header(pdf, query, col_width, row_height, table_width) | 
| 331 | 341 | base_y = pdf.get_y | 
| 332 | 342 | end | 
| 333 |  | |
| 343 | ||
| 334 | 344 | # write the cells on page | 
| 335 | 345 | issues_to_pdf_write_cells(pdf, col_values, col_width, max_height) | 
| 336 | 346 | pdf.set_y(base_y + max_height) | 
| 337 |  | |
| 347 | ||
| 338 | 348 | if query.has_column?(:description) && issue.description? | 
| 339 | 349 | pdf.set_x(10) | 
| 340 | 350 | pdf.set_auto_page_break(true, bottom_margin) | 
| ... | ... | |
| 349 | 359 | pdf.set_auto_page_break(false) | 
| 350 | 360 | end | 
| 351 | 361 | end | 
| 352 |  | |
| 362 | ||
| 353 | 363 | if issues.size == Setting.issues_export_limit.to_i | 
| 354 | 364 |             pdf.SetFontStyle('B',10) | 
| 355 | 365 | pdf.RDMCell(0, row_height, '...') | 
| ... | ... | |
| 379 | 389 | value = " " * level + value | 
| 380 | 390 | when :attachments | 
| 381 | 391 |                 value = value.to_a.map {|a| a.filename}.join("\n") | 
| 392 | when :tags_list | |
| 393 | value = value.to_s | |
| 382 | 394 | end | 
| 383 | 395 | if value.is_a?(Date) | 
| 384 | 396 | format_date(value) | 
| test/fixtures/taggings.yml | ||
|---|---|---|
| 1 | --- | |
| 2 | tagging_1: | |
| 3 | tag_id: 1 | |
| 4 | taggable_id: 1 | |
| 5 | taggable_type: Issue | |
| 6 | context: tags | |
| 7 | created_at: <%= 2.days.ago.to_s(:db) %> | |
| 8 | tagging_2: | |
| 9 | tag_id: 2 | |
| 10 | taggable_id: 1 | |
| 11 | taggable_type: Issue | |
| 12 | context: tags | |
| 13 | created_at: <%= 2.days.ago.to_s(:db) %> | |
| 14 | tagging_3: | |
| 15 | tag_id: 1 | |
| 16 | taggable_id: 2 | |
| 17 | taggable_type: Issue | |
| 18 | context: tags | |
| 19 | created_at: <%= 2.days.ago.to_s(:db) %> | |
| test/fixtures/tags.yml | ||
|---|---|---|
| 1 | --- | |
| 2 | tag_001: | |
| 3 | id: 1 | |
| 4 | name: UX | |
| 5 | tag_002: | |
| 6 | id: 2 | |
| 7 | name: Backend | |
| 8 | tag_003: | |
| 9 | id: 3 | |
| 10 | name: API | |
| test/functional/issues_controller_test.rb | ||
|---|---|---|
| 43 | 43 | :journal_details, | 
| 44 | 44 | :queries, | 
| 45 | 45 | :repositories, | 
| 46 | :changesets | |
| 46 | :changesets, | |
| 47 | :tags, | |
| 48 | :taggings | |
| 47 | 49 | |
| 48 | 50 | include Redmine::I18n | 
| 49 | 51 | |
| ... | ... | |
| 1558 | 1560 | end | 
| 1559 | 1561 | end | 
| 1560 | 1562 | |
| 1563 | def test_index_with_tags_column | |
| 1564 |     get :index, :params => { | |
| 1565 | :set_filter => 1, | |
| 1566 | :project_id => 1, | |
| 1567 | :c => %w(subject tags_list) | |
| 1568 | } | |
| 1569 | ||
| 1570 | assert_response :success | |
| 1571 | assert_select 'td.tags_list', :text => 'UX' | |
| 1572 | assert_select 'td.tags_list', :text => 'UX, Backend' | |
| 1573 | ||
| 1574 |       get :index, :params => { | |
| 1575 | :set_filter => 1, | |
| 1576 | :project_id => 1, | |
| 1577 | :c => %w(subject tags_list), | |
| 1578 | :format => 'pdf' | |
| 1579 | } | |
| 1580 | assert_response :success | |
| 1581 | assert_equal 'application/pdf', response.content_type | |
| 1582 | end | |
| 1583 | ||
| 1561 | 1584 | def test_show_by_anonymous | 
| 1562 | 1585 |     get :show, :params => { | 
| 1563 | 1586 | :id => 1 | 
| ... | ... | |
| 2312 | 2335 | assert_select 'a', :text => 'Delete', :count => 0 | 
| 2313 | 2336 | end | 
| 2314 | 2337 | |
| 2338 | def test_show_should_render_issue_tags_for_issue_with_tags | |
| 2339 | @request.session[:user_id] = 1 | |
| 2340 | ||
| 2341 |     get :show, :params => { | |
| 2342 | :id => 1 | |
| 2343 | } | |
| 2344 | ||
| 2345 | assert_response :success | |
| 2346 | assert_select 'div.tags .value', :text => 'UX, Backend', :count => 1 | |
| 2347 | end | |
| 2348 | ||
| 2349 | def test_show_should_not_render_issue_tags_for_issue_without_tags | |
| 2350 | @request.session[:user_id] = 1 | |
| 2351 | ||
| 2352 |     get :show, :params => { | |
| 2353 | :id => 14 | |
| 2354 | } | |
| 2355 | ||
| 2356 | assert_response :success | |
| 2357 | assert_select 'div.tags', 0 | |
| 2358 | end | |
| 2359 | ||
| 2315 | 2360 | def test_get_new | 
| 2316 | 2361 | @request.session[:user_id] = 2 | 
| 2317 | 2362 |     get :new, :params => { | 
| ... | ... | |
| 2338 | 2383 | assert_select 'select[name=?]', 'issue[done_ratio]' | 
| 2339 | 2384 | assert_select 'input[name=?][value=?]', 'issue[custom_field_values][2]', 'Default string' | 
| 2340 | 2385 | assert_select 'input[name=?]', 'issue[watcher_user_ids][]' | 
| 2386 | assert_select 'input[name=?]', 'issue[tag_list]' | |
| 2341 | 2387 | end | 
| 2342 | 2388 | |
| 2343 | 2389 | # Be sure we don't display inactive IssuePriorities | 
| ... | ... | |
| 3291 | 3337 | assert [mail.bcc, mail.cc].flatten.include?(User.find(3).mail) | 
| 3292 | 3338 | end | 
| 3293 | 3339 | |
| 3340 | def test_post_create_with_tags | |
| 3341 | @request.session[:user_id] = 2 | |
| 3342 | ||
| 3343 |     post :create, :params => { | |
| 3344 | :project_id => 1, | |
| 3345 |         :issue => { | |
| 3346 | :tracker_id => 1, | |
| 3347 | :subject => 'This is a new issue with tags', | |
| 3348 | :description => 'This is the description', | |
| 3349 | :priority_id => 5, | |
| 3350 | :tag_list => 'Two, Three' | |
| 3351 | } | |
| 3352 | } | |
| 3353 | ||
| 3354 |     issue = Issue.order('id DESC').first | |
| 3355 | assert_equal ['Two', 'Three'], issue.tag_list | |
| 3356 | end | |
| 3357 | ||
| 3294 | 3358 | def test_post_create_subissue | 
| 3295 | 3359 | @request.session[:user_id] = 2 | 
| 3296 | 3360 | |
| ... | ... | |
| 4644 | 4708 | :project_id => '1', | 
| 4645 | 4709 | :tracker_id => '2', | 
| 4646 | 4710 | :priority_id => '6' | 
| 4647 | ||
| 4648 | 4711 | } | 
| 4649 | 4712 | } | 
| 4650 | 4713 | end | 
| ... | ... | |
| 5261 | 5324 | assert_equal 'Original subject', issue.reload.subject | 
| 5262 | 5325 | end | 
| 5263 | 5326 | |
| 5327 | def test_put_update_issue_tags | |
| 5328 | @request.session[:user_id] = 1 | |
| 5329 | ||
| 5330 |     put :update, :params => { | |
| 5331 | :id => 1, | |
| 5332 |         :issue => { | |
| 5333 | :tag_list => 'Three' | |
| 5334 | } | |
| 5335 | } | |
| 5336 | assert_response 302 | |
| 5337 | ||
| 5338 | assert_equal ['Three'], Issue.find(1).tag_list | |
| 5339 | end | |
| 5340 | ||
| 5264 | 5341 | def test_get_bulk_edit | 
| 5265 | 5342 | @request.session[:user_id] = 2 | 
| 5266 | 5343 |     get :bulk_edit, :params => { | 
| test/functional/queries_controller_test.rb | ||
|---|---|---|
| 23 | 23 | :members, :member_roles, :roles, | 
| 24 | 24 | :trackers, :issue_statuses, :issue_categories, :enumerations, :versions, | 
| 25 | 25 | :issues, :custom_fields, :custom_values, | 
| 26 | :queries | |
| 26 |            :queries, :tags, :taggings | |
| 27 | 27 | |
| 28 | 28 | def setup | 
| 29 | 29 | User.current = nil | 
| ... | ... | |
| 732 | 732 | assert_include ["Dave Lopper", "3", "active"], json | 
| 733 | 733 | assert_include ["Dave2 Lopper2", "5", "locked"], json | 
| 734 | 734 | end | 
| 735 | ||
| 736 | def test_filter_with_tags_should_return_filter_values | |
| 737 | @request.session[:user_id] = 2 | |
| 738 |     get :filter, :params => { | |
| 739 | :project_id => 1, | |
| 740 | :type => 'IssueQuery', | |
| 741 | :name => 'tags_list' | |
| 742 | } | |
| 743 | ||
| 744 | assert_response :success | |
| 745 | assert_equal 'application/json', response.content_type | |
| 746 | json = ActiveSupport::JSON.decode(response.body) | |
| 747 | assert_equal [["UX","UX"],["Backend","Backend"]], json | |
| 748 | end | |
| 735 | 749 | end | 
| test/unit/issue_tags_test.rb | ||
|---|---|---|
| 1 | # Redmine - project management software | |
| 2 | # Copyright (C) 2006-2017 Jean-Philippe Lang | |
| 3 | # | |
| 4 | # This program is free software; you can redistribute it and/or | |
| 5 | # modify it under the terms of the GNU General Public License | |
| 6 | # as published by the Free Software Foundation; either version 2 | |
| 7 | # of the License, or (at your option) any later version. | |
| 8 | # | |
| 9 | # This program is distributed in the hope that it will be useful, | |
| 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| 12 | # GNU General Public License for more details. | |
| 13 | # | |
| 14 | # You should have received a copy of the GNU General Public License | |
| 15 | # along with this program; if not, write to the Free Software | |
| 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |
| 17 | ||
| 18 | require File.expand_path('../../test_helper', __FILE__) | |
| 19 | ||
| 20 | class IssueTagsTest < ActiveSupport::TestCase | |
| 21 | fixtures :projects, :users, :email_addresses, :user_preferences, :members, :member_roles, :roles, | |
| 22 | :groups_users, | |
| 23 | :trackers, :projects_trackers, | |
| 24 | :enabled_modules, | |
| 25 | :issue_statuses, | |
| 26 | :issues, :journals, :journal_details, | |
| 27 | :tags, :taggings | |
| 28 | ||
| 29 | include Redmine::I18n | |
| 30 | ||
| 31 | def setup | |
| 32 | set_language_if_valid 'en' | |
| 33 | end | |
| 34 | ||
| 35 | def teardown | |
| 36 | User.current = nil | |
| 37 | end | |
| 38 | ||
| 39 | def test_issue_tag_list_should_return_an_array_of_tags | |
| 40 | assert_equal ['UX', 'Backend'], Issue.find(1).tag_list | |
| 41 | end | |
| 42 | ||
| 43 | def test_issue_tag_list_to_s_should_return_a_string_of_tags_delimited_by_comma | |
| 44 | assert_equal 'UX, Backend', Issue.find(1).tag_list.to_s | |
| 45 | end | |
| 46 | ||
| 47 | def test_add_issue_tags | |
| 48 | issue = Issue.find(2) | |
| 49 | issue.tag_list = "One, Two" | |
| 50 | ||
| 51 | assert issue.save! | |
| 52 | ||
| 53 | issue.reload | |
| 54 | ||
| 55 | assert_equal ['One', 'Two'], issue.tag_list | |
| 56 | end | |
| 57 | ||
| 58 | def test_clear_issue_tags | |
| 59 | issue = Issue.find(1) | |
| 60 | issue.tag_list = '' | |
| 61 | ||
| 62 | assert issue.save! | |
| 63 | assert_equal [], issue.tag_list | |
| 64 | end | |
| 65 | ||
| 66 | def test_update_issue_tags_should_journalize_changes | |
| 67 | issue = Issue.find(1) | |
| 68 | issue.init_journal User.find(1) | |
| 69 | issue.tag_list = "UX, API" | |
| 70 | ||
| 71 | assert_difference 'Journal.count', 1 do | |
| 72 | assert_difference 'JournalDetail.count', 1 do | |
| 73 | issue.save! | |
| 74 | end | |
| 75 | end | |
| 76 | issue.reload | |
| 77 | ||
| 78 | assert_equal ['UX', 'API'], issue.tag_list | |
| 79 | ||
| 80 |     detail = JournalDetail.order('id DESC').first | |
| 81 | assert_equal issue, detail.journal.journalized | |
| 82 | assert_equal 'attr', detail.property | |
| 83 | assert_equal 'tags', detail.prop_key | |
| 84 | assert_equal 'UX, Backend', detail.old_value | |
| 85 | assert_equal 'UX, API', detail.value | |
| 86 | end | |
| 87 | ||
| 88 | def test_update_issue_tags_should_not_journalize_changes_if_tags_are_not_changed | |
| 89 | issue = Issue.find(1) | |
| 90 | issue.init_journal User.find(1) | |
| 91 | issue.tag_list = "UX, Backend" | |
| 92 | ||
| 93 | assert_difference 'Journal.count', 0 do | |
| 94 | assert_difference 'JournalDetail.count', 0 do | |
| 95 | issue.save! | |
| 96 | end | |
| 97 | end | |
| 98 | end | |
| 99 | end | |
| test/unit/lib/redmine/export/pdf/issues_pdf_test.rb | ||
|---|---|---|
| 19 | 19 | |
| 20 | 20 | class IssuesPdfHelperTest < ActiveSupport::TestCase | 
| 21 | 21 | fixtures :users, :projects, :roles, :members, :member_roles, | 
| 22 | :enabled_modules, :issues, :trackers, :enumerations | |
| 22 | :enabled_modules, :issues, :trackers, :enumerations, | |
| 23 | :tags, :taggings | |
| 23 | 24 | |
| 24 | 25 | include Redmine::Export::PDF::IssuesPdfHelper | 
| 25 | 26 | |
| ... | ... | |
| 32 | 33 | results = fetch_row_values(issue, query, 0) | 
| 33 | 34 | assert_equal ["2", "Add ingredients categories", "4.34"], results | 
| 34 | 35 | end | 
| 36 | ||
| 37 | def test_fetch_row_values_should_return_issue_tags_as_string | |
| 38 | query = IssueQuery.new(:project => Project.find(1), :name => '_') | |
| 39 | query.column_names = [:subject, :tags_list] | |
| 40 | ||
| 41 | results = fetch_row_values(Issue.find(1), query, 0) | |
| 42 | ||
| 43 | assert_equal ["1", "Cannot print recipes", "UX, Backend"], results | |
| 44 | end | |
| 35 | 45 | end | 
| test/unit/query_test.rb | ||
|---|---|---|
| 30 | 30 | :projects_trackers, | 
| 31 | 31 | :custom_fields_trackers, | 
| 32 | 32 | :workflows, :journals, | 
| 33 | :attachments | |
| 33 | :attachments, | |
| 34 | :tags, :taggings | |
| 34 | 35 | |
| 35 | 36 | INTEGER_KLASS = RUBY_VERSION >= "2.4" ? Integer : Fixnum | 
| 36 | 37 | |
| ... | ... | |
| 821 | 822 | end | 
| 822 | 823 | end | 
| 823 | 824 | |
| 825 | def test_filter_by_tags_with_operator_is | |
| 826 | query = IssueQuery.new(:name => '_') | |
| 827 | filter_name = "tags_list" | |
| 828 | assert_include filter_name, query.available_filters.keys | |
| 829 | ||
| 830 |     query.filters = {filter_name => {:operator => '=', :values => ['UX']}} | |
| 831 | assert_equal [1, 2], find_issues_with_query(query).map(&:id).sort | |
| 832 | ||
| 833 | # Should return issue tagged with any of the values | |
| 834 |     query.filters = {filter_name => {:operator => '=', :values => ['UX, Backend']}} | |
| 835 | assert_equal [1, 2], find_issues_with_query(query).map(&:id).sort | |
| 836 | end | |
| 837 | ||
| 838 | def test_filter_by_tags_with_operator_is_not | |
| 839 | query = IssueQuery.new(:name => '_') | |
| 840 | filter_name = "tags_list" | |
| 841 | assert_include filter_name, query.available_filters.keys | |
| 842 | ||
| 843 |     query.filters = {filter_name => {:operator => '!', :values => ['Backend']}} | |
| 844 | issues = find_issues_with_query(query).map(&:id).sort | |
| 845 | ||
| 846 | # Issue tagged with Backend should not be returned | |
| 847 | assert_not_include 1, issues | |
| 848 | assert_include 2, issues | |
| 849 | # Untagged issues should be returned | |
| 850 | assert_include 5, issues | |
| 851 | end | |
| 852 | ||
| 853 | def test_filter_by_tags_with_operator_none | |
| 854 | query = IssueQuery.new(:name => '_') | |
| 855 | filter_name = "tags_list" | |
| 856 | assert_include filter_name, query.available_filters.keys | |
| 857 | ||
| 858 |     query.filters = {filter_name => {:operator => '!*', :values => ['']}} | |
| 859 | issues = find_issues_with_query(query).map(&:id).sort | |
| 860 | ||
| 861 | # Tagged issues should not be returned | |
| 862 | assert_not_include 1, issues | |
| 863 | assert_not_include 2, issues | |
| 864 | ||
| 865 | # Untagged issues should be returned | |
| 866 | assert_include 5, issues | |
| 867 | end | |
| 868 | ||
| 869 | def test_filter_by_tags_with_operator_any | |
| 870 | query = IssueQuery.new(:name => '_') | |
| 871 | filter_name = "tags_list" | |
| 872 | assert_include filter_name, query.available_filters.keys | |
| 873 | ||
| 874 |     query.filters = {filter_name => {:operator => '*', :values => ['']}} | |
| 875 | assert_equal [1, 2], find_issues_with_query(query).map(&:id).sort | |
| 876 | end | |
| 877 | ||
| 824 | 878 | def test_user_custom_field_filtered_on_me | 
| 825 | 879 | User.current = User.find(2) | 
| 826 | 880 | cf = IssueCustomField.create!(:field_format => 'user', :is_for_all => true, :is_filter => true, :name => 'User custom field', :tracker_ids => [1]) | 
| ... | ... | |
| 1400 | 1454 |     assert_not_nil issues.first.instance_variable_get("@last_notes") | 
| 1401 | 1455 | end | 
| 1402 | 1456 | |
| 1457 | def test_query_should_preload_tags | |
| 1458 | q = IssueQuery.new(:name => '_', :column_names => [:subject, :tags_list]) | |
| 1459 | assert q.has_column?(:tags_list) | |
| 1460 | issues = q.issues | |
| 1461 |     assert_not_nil issues.first.instance_variable_get("@tags_list") | |
| 1462 | end | |
| 1463 | ||
| 1403 | 1464 | def test_groupable_columns_should_include_custom_fields | 
| 1404 | 1465 | q = IssueQuery.new | 
| 1405 | 1466 |     column = q.groupable_columns.detect {|c| c.name == :cf_1} |