diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index bcccfd29b..4743eda43 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -64,10 +64,12 @@ module ApplicationHelper # link_to_issue(issue, :truncate => 6) # => Defect #6: This i... # link_to_issue(issue, :subject => false) # => Defect #6 # link_to_issue(issue, :project => true) # => Foo - Defect #6 + # link_to_issue(issue, :subject => false, :tracker => false) # => #6 # def link_to_issue(issue, options={}) title = nil subject = nil + text = options[:tracker] == false ? "##{issue.id}" : "#{issue.tracker} ##{issue.id}" if options[:subject] == false title = truncate(issue.subject, :length => 60) else @@ -76,7 +78,7 @@ module ApplicationHelper subject = truncate(subject, :length => options[:truncate]) end end - s = link_to "#{h(issue.tracker)} ##{issue.id}", {:controller => "issues", :action => "show", :id => issue}, + s = link_to text, {:controller => "issues", :action => "show", :id => issue}, :class => issue.css_classes, :title => title s << h(": #{subject}") if subject diff --git a/app/helpers/queries_helper.rb b/app/helpers/queries_helper.rb index 076a8959e..ca452a41a 100644 --- a/app/helpers/queries_helper.rb +++ b/app/helpers/queries_helper.rb @@ -35,7 +35,7 @@ module QueriesHelper def column_content(column, issue) value = column.value(issue) if value.is_a?(Array) - value.collect {|v| column_value(column, issue, v)}.compact.sort.join(', ').html_safe + value.collect {|v| column_value(column, issue, v)}.compact.join(', ').html_safe else column_value(column, issue, value) end @@ -73,6 +73,11 @@ module QueriesHelper l(:general_text_No) when 'Issue' link_to_issue(value, :subject => false) + when 'IssueRelation' + other = value.other_issue(issue) + content_tag('span', + (l(value.label_for(issue)) + " " + link_to_issue(other, :subject => false, :tracker => false)).html_safe, + :class => value.css_classes_for(issue)) else h(value) end diff --git a/app/models/issue.rb b/app/models/issue.rb index b44154340..2f1021b2d 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -752,7 +752,7 @@ class Issue < ActiveRecord::Base end def relations - @relations ||= (relations_from + relations_to).sort + @relations ||= IssueRelations.new(self, (relations_from + relations_to).sort) end # Preloads relations for a collection of issues @@ -775,6 +775,25 @@ class Issue < ActiveRecord::Base end end + # Preloads visible relations for a collection of issues + def self.load_visible_relations(issues, user=User.current) + if issues.any? + issue_ids = issues.map(&:id) + # Relations with issue_from in given issues and visible issue_to + relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all + # Relations with issue_to in given issues and visible issue_from + relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all + + issues.each do |issue| + relations = + relations_from.select {|relation| relation.issue_from_id == issue.id} + + relations_to.select {|relation| relation.issue_to_id == issue.id} + + issue.instance_variable_set "@relations", IssueRelations.new(issue, relations.sort) + end + end + end + # Finds an issue relation given its id. def find_relation(relation_id) IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id]) diff --git a/app/models/issue_relation.rb b/app/models/issue_relation.rb index c145b87b9..285c4e306 100644 --- a/app/models/issue_relation.rb +++ b/app/models/issue_relation.rb @@ -15,6 +15,20 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# Class used to represent the relations of an issue +class IssueRelations < Array + include Redmine::I18n + + def initialize(issue, *args) + @issue = issue + super(*args) + end + + def to_s(*args) + map {|relation| "#{l(relation.label_for(@issue))} ##{relation.other_issue(@issue).id}"}.join(', ') + end +end + class IssueRelation < ActiveRecord::Base belongs_to :issue_from, :class_name => 'Issue', :foreign_key => 'issue_from_id' belongs_to :issue_to, :class_name => 'Issue', :foreign_key => 'issue_to_id' @@ -103,6 +117,10 @@ class IssueRelation < ActiveRecord::Base TYPES[relation_type] ? TYPES[relation_type][(self.issue_from_id == issue.id) ? :name : :sym_name] : :unknow end + def css_classes_for(issue) + "rel-#{relation_type_for(issue)}" + end + def handle_issue_order reverse_if_needed @@ -128,7 +146,8 @@ class IssueRelation < ActiveRecord::Base end def <=>(relation) - TYPES[self.relation_type][:order] <=> TYPES[relation.relation_type][:order] + r = TYPES[self.relation_type][:order] <=> TYPES[relation.relation_type][:order] + r == 0 ? id <=> relation.id : r end private diff --git a/app/models/query.rb b/app/models/query.rb index ddf4d87d9..92a568119 100644 --- a/app/models/query.rb +++ b/app/models/query.rb @@ -113,7 +113,9 @@ class Query < ActiveRecord::Base " :label_more_than_ago, "t-" => :label_ago, "~" => :label_contains, - "!~" => :label_not_contains } + "!~" => :label_not_contains, + "=p" => :label_any_issues_in_project, + "=!p" => :label_any_issues_not_in_project} cattr_reader :operators @@ -126,7 +128,8 @@ class Query < ActiveRecord::Base :string => [ "=", "~", "!", "!~", "!*", "*" ], :text => [ "~", "!~", "!*", "*" ], :integer => [ "=", ">=", "<=", "><", "!*", "*" ], - :float => [ "=", ">=", "<=", "><", "!*", "*" ] } + :float => [ "=", ">=", "<=", "><", "!*", "*" ], + :relation => ["=", "=p", "=!p", "!*", "*"]} cattr_reader :operators_by_filter_type @@ -147,6 +150,7 @@ class Query < ActiveRecord::Base QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"), QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true), QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'), + QueryColumn.new(:relations, :caption => :label_related_issues) ] cattr_reader :available_columns @@ -233,6 +237,10 @@ class Query < ActiveRecord::Base "estimated_hours" => { :type => :float, :order => 13 }, "done_ratio" => { :type => :integer, :order => 14 }} + IssueRelation::TYPES.each do |relation_type, options| + @available_filters[relation_type] = {:type => :relation, :order => @available_filters.size + 100, :label => options[:name]} + end + principals = [] if project principals += project.principals.sort @@ -244,7 +252,6 @@ class Query < ActiveRecord::Base end end else - all_projects = Project.visible.all if all_projects.any? # members of visible projects principals += Principal.member_of(all_projects) @@ -254,10 +261,7 @@ class Query < ActiveRecord::Base if User.current.logged? && User.current.memberships.any? project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"] end - Project.project_tree(all_projects) do |p, level| - prefix = (level > 0 ? ('--' * level + ' ') : '') - project_values << ["#{prefix}#{p.name}", p.id.to_s] - end + project_values += all_projects_values @available_filters["project_id"] = { :type => :list, :order => 1, :values => project_values} unless project_values.empty? end end @@ -317,7 +321,7 @@ class Query < ActiveRecord::Base } @available_filters.each do |field, options| - options[:name] ||= l("field_#{field}".gsub(/_id$/, '')) + options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, '')) end @available_filters @@ -332,6 +336,21 @@ class Query < ActiveRecord::Base json end + def all_projects + @all_projects ||= Project.visible.all + end + + def all_projects_values + return @all_projects_values if @all_projects_values + + values = [] + Project.project_tree(all_projects) do |p, level| + prefix = (level > 0 ? ('--' * level + ' ') : '') + values << ["#{prefix}#{p.name}", p.id.to_s] + end + @all_projects_values = values + end + def add_filter(field, operator, values) # values must be an array return unless values.nil? || values.is_a?(Array) @@ -635,6 +654,9 @@ class Query < ActiveRecord::Base if has_column?(:spent_hours) Issue.load_visible_spent_hours(issues) end + if has_column?(:relations) + Issue.load_visible_relations(issues) + end issues rescue ::ActiveRecord::StatementInvalid => e raise StatementInvalid.new(e.message) @@ -729,6 +751,41 @@ class Query < ActiveRecord::Base "#{Issue.table_name}.is_private #{op} (#{va})" end + def sql_for_relations(field, operator, value, options={}) + relation_options = IssueRelation::TYPES[field] + return relation_options unless relation_options + + relation_type = field + join_column, target_join_column = "issue_from_id", "issue_to_id" + if relation_options[:reverse] || options[:reverse] + relation_type = relation_options[:reverse] || relation_type + join_column, target_join_column = target_join_column, join_column + end + + sql = case operator + when "*", "!*" + op = (operator == "*" ? 'IN' : 'NOT IN') + "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}')" + when "=", "!" + op = (operator == "=" ? 'IN' : 'NOT IN') + "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = #{value.first.to_i})" + when "=p", "=!p" + op = (operator == "=p" ? '=' : '<>') + "#{Issue.table_name}.id IN (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name}, #{Issue.table_name} relissues WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = relissues.id AND relissues.project_id #{op} #{value.first.to_i})" + end + + if relation_options[:sym] == field && !options[:reverse] + sqls = [sql, sql_for_relations(field, operator, value, :reverse => true)] + sqls.join(["!", "!*"].include?(operator) ? " AND " : " OR ") + else + sql + end + end + + IssueRelation::TYPES.keys.each do |relation_type| + alias_method "sql_for_#{relation_type}_field".to_sym, :sql_for_relations + end + private def sql_for_custom_field(field, operator, value, custom_field_id) diff --git a/app/views/queries/_filters.html.erb b/app/views/queries/_filters.html.erb index f9e371b7e..795f8075f 100644 --- a/app/views/queries/_filters.html.erb +++ b/app/views/queries/_filters.html.erb @@ -3,6 +3,7 @@ var operatorLabels = <%= raw_json Query.operators_labels %>; var operatorByType = <%= raw_json Query.operators_by_filter_type %>; var availableFilters = <%= raw_json query.available_filters_as_json %>; var labelDayPlural = <%= raw_json l(:label_day_plural) %>; +var allProjects = <%= raw query.all_projects_values.to_json %>; $(document).ready(function(){ initFilters(); <% query.filters.each do |field, options| %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 7ccf38231..b72dfea66 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -669,6 +669,8 @@ en: label_ago: days ago label_contains: contains label_not_contains: doesn't contain + label_any_issues_in_project: any issues in project + label_any_issues_not_in_project: any issues not in project label_day_plural: days label_repository: Repository label_repository_new: New repository @@ -737,15 +739,15 @@ en: label_loading: Loading... label_relation_new: New relation label_relation_delete: Delete relation - label_relates_to: related to - label_duplicates: duplicates - label_duplicated_by: duplicated by - label_blocks: blocks - label_blocked_by: blocked by - label_precedes: precedes - label_follows: follows - label_copied_to: copied to - label_copied_from: copied from + label_relates_to: Related to + label_duplicates: Duplicates + label_duplicated_by: Duplicated by + label_blocks: Blocks + label_blocked_by: Blocked by + label_precedes: Precedes + label_follows: Follows + label_copied_to: Copied to + label_copied_from: Copied from label_end_to_start: end to start label_end_to_end: end to end label_start_to_start: start to start diff --git a/config/locales/fr.yml b/config/locales/fr.yml index f71de45a2..d2ab50ffc 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -659,6 +659,8 @@ fr: label_ago: il y a label_contains: contient label_not_contains: ne contient pas + label_any_issues_in_project: une demande du projet + label_any_issues_not_in_project: une demande hors du projet label_day_plural: jours label_repository: Dépôt label_repository_new: Nouveau dépôt @@ -721,15 +723,15 @@ fr: label_loading: Chargement... label_relation_new: Nouvelle relation label_relation_delete: Supprimer la relation - label_relates_to: lié à - label_duplicates: duplique - label_duplicated_by: dupliqué par - label_blocks: bloque - label_blocked_by: bloqué par - label_precedes: précède - label_follows: suit - label_copied_to: copié vers - label_copied_from: copié depuis + label_relates_to: Lié à + label_duplicates: Duplique + label_duplicated_by: Dupliqué par + label_blocks: Bloque + label_blocked_by: Bloqué par + label_precedes: Précède + label_follows: Suit + label_copied_to: Copié vers + label_copied_from: Copié depuis label_end_to_start: fin à début label_end_to_end: fin à fin label_start_to_start: début à début diff --git a/public/javascripts/application.js b/public/javascripts/application.js index 5fa9366a7..3f2b2392c 100644 --- a/public/javascripts/application.js +++ b/public/javascripts/application.js @@ -178,6 +178,20 @@ function buildFilterRow(field, operator, values) { ); $('#values_'+fieldId).val(values[0]); break; + case "relation": + tr.find('td.values').append( + '' + + '' + ); + $('#values_'+fieldId).val(values[0]); + select = tr.find('td.values select'); + for (i=0;i'); + option.val(filterValue[1]).text(filterValue[0]); + if (values[0] == filterValue[1]) {option.attr('selected', true)}; + select.append(option); + } case "integer": case "float": tr.find('td.values').append( @@ -244,6 +258,10 @@ function toggleOperator(field) { case "t-": enableValues(field, [2]); break; + case "=p": + case "=!p": + enableValues(field, [1]); + break; default: enableValues(field, [0]); break; diff --git a/public/stylesheets/application.css b/public/stylesheets/application.css index fe6d7b458..fff82cedf 100644 --- a/public/stylesheets/application.css +++ b/public/stylesheets/application.css @@ -120,7 +120,7 @@ a#toggle-completed-versions {color:#999;} /***** Tables *****/ table.list { border: 1px solid #e4e4e4; border-collapse: collapse; width: 100%; margin-bottom: 4px; } table.list th { background-color:#EEEEEE; padding: 4px; white-space:nowrap; } -table.list td { vertical-align: top; } +table.list td { vertical-align: top; padding-right:10px; } table.list td.id { width: 2%; text-align: center;} table.list td.checkbox { width: 15px; padding: 2px 0 0 0; } table.list td.checkbox input {padding:0px;} @@ -144,9 +144,10 @@ tr.project.idnt-8 td.name {padding-left: 11em;} tr.project.idnt-9 td.name {padding-left: 12.5em;} tr.issue { text-align: center; white-space: nowrap; } -tr.issue td.subject, tr.issue td.category, td.assigned_to, tr.issue td.string, tr.issue td.text { white-space: normal; } -tr.issue td.subject { text-align: left; } +tr.issue td.subject, tr.issue td.category, td.assigned_to, tr.issue td.string, tr.issue td.text, tr.issue td.relations { white-space: normal; } +tr.issue td.subject, tr.issue td.relations { text-align: left; } tr.issue td.done_ratio table.progress { margin-left:auto; margin-right: auto;} +tr.issue td.relations span {white-space: nowrap;} tr.issue.idnt td.subject a {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;} tr.issue.idnt-1 td.subject {padding-left: 0.5em;} @@ -340,12 +341,14 @@ fieldset#date-range p { margin: 2px 0 2px 0; } fieldset#filters table { border-collapse: collapse; } fieldset#filters table td { padding: 0; vertical-align: middle; } fieldset#filters tr.filter { height: 2.1em; } -fieldset#filters td.field { width:250px; } -fieldset#filters td.operator { width:170px; } +fieldset#filters td.field { width:230px; } +fieldset#filters td.operator { width:180px; } +fieldset#filters td.operator select {max-width:170px;} fieldset#filters td.values { white-space:nowrap; } fieldset#filters td.values select {min-width:130px;} fieldset#filters td.values input {height:1em;} fieldset#filters td.add-filter { text-align: right; vertical-align: top; } + .toggle-multiselect {background: url(../images/bullet_toggle_plus.png) no-repeat 0% 40%; padding-left:8px; margin-left:0; cursor:pointer;} .buttons { font-size: 0.9em; margin-bottom: 1.4em; margin-top: 1em; } diff --git a/test/functional/issues_controller_test.rb b/test/functional/issues_controller_test.rb index 8f8f1e27b..e5079b5a6 100644 --- a/test/functional/issues_controller_test.rb +++ b/test/functional/issues_controller_test.rb @@ -766,7 +766,7 @@ class IssuesControllerTest < ActionController::TestCase end end - def test_index_with_done_ratio + def test_index_with_done_ratio_column Issue.find(1).update_attribute :done_ratio, 40 get :index, :set_filter => 1, :c => %w(done_ratio) @@ -792,12 +792,48 @@ class IssuesControllerTest < ActionController::TestCase assert_no_tag 'td', :attributes => {:class => /spent_hours/} end - def test_index_with_fixed_version + def test_index_with_fixed_version_column get :index, :set_filter => 1, :c => %w(fixed_version) assert_tag 'td', :attributes => {:class => /fixed_version/}, :child => {:tag => 'a', :content => '1.0', :attributes => {:href => '/versions/2'}} end + def test_index_with_relations_column + IssueRelation.delete_all + IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Issue.find(7)) + IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(8), :issue_to => Issue.find(1)) + IssueRelation.create!(:relation_type => "blocks", :issue_from => Issue.find(1), :issue_to => Issue.find(11)) + IssueRelation.create!(:relation_type => "blocks", :issue_from => Issue.find(12), :issue_to => Issue.find(2)) + + get :index, :set_filter => 1, :c => %w(subject relations) + assert_response :success + assert_select "tr#issue-1 td.relations" do + assert_select "span", 3 + assert_select "span", :text => "Related to #7" + assert_select "span", :text => "Related to #8" + assert_select "span", :text => "Blocks #11" + end + assert_select "tr#issue-2 td.relations" do + assert_select "span", 1 + assert_select "span", :text => "Blocked by #12" + end + assert_select "tr#issue-3 td.relations" do + assert_select "span", 0 + end + + get :index, :set_filter => 1, :c => %w(relations), :format => 'csv' + assert_response :success + assert_equal 'text/csv; header=present', response.content_type + lines = response.body.chomp.split("\n") + assert_include '1,"Related to #7, Related to #8, Blocks #11"', lines + assert_include '2,Blocked by #12', lines + assert_include '3,""', lines + + get :index, :set_filter => 1, :c => %w(subject relations), :format => 'pdf' + assert_response :success + assert_equal 'application/pdf', response.content_type + end + def test_index_send_html_if_query_is_invalid get :index, :f => ['start_date'], :op => {:start_date => '='} assert_equal 'text/html', @response.content_type diff --git a/test/unit/query_test.rb b/test/unit/query_test.rb index 5f639a84f..fac5b97dc 100644 --- a/test/unit/query_test.rb +++ b/test/unit/query_test.rb @@ -624,6 +624,76 @@ class QueryTest < ActiveSupport::TestCase assert_equal [2], find_issues_with_query(query).map(&:fixed_version_id).uniq.sort end + def test_filter_on_relations_with_a_specific_issue + IssueRelation.delete_all + IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Issue.find(2)) + IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(3), :issue_to => Issue.find(1)) + + query = Query.new(:name => '_') + query.filters = {"relates" => {:operator => '=', :values => ['1']}} + assert_equal [2, 3], find_issues_with_query(query).map(&:id).sort + + query = Query.new(:name => '_') + query.filters = {"relates" => {:operator => '=', :values => ['2']}} + assert_equal [1], find_issues_with_query(query).map(&:id).sort + end + + def test_filter_on_relations_with_any_issues_in_a_project + IssueRelation.delete_all + with_settings :cross_project_issue_relations => '1' do + IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(2).issues.first) + IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(2), :issue_to => Project.find(2).issues.first) + IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(3).issues.first) + end + + query = Query.new(:name => '_') + query.filters = {"relates" => {:operator => '=p', :values => ['2']}} + assert_equal [1, 2], find_issues_with_query(query).map(&:id).sort + + query = Query.new(:name => '_') + query.filters = {"relates" => {:operator => '=p', :values => ['3']}} + assert_equal [1], find_issues_with_query(query).map(&:id).sort + + query = Query.new(:name => '_') + query.filters = {"relates" => {:operator => '=p', :values => ['4']}} + assert_equal [], find_issues_with_query(query).map(&:id).sort + end + + def test_filter_on_relations_with_any_issues_not_in_a_project + IssueRelation.delete_all + with_settings :cross_project_issue_relations => '1' do + IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(2).issues.first) + #IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(2), :issue_to => Project.find(1).issues.first) + IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(3).issues.first) + end + + query = Query.new(:name => '_') + query.filters = {"relates" => {:operator => '=!p', :values => ['1']}} + assert_equal [1], find_issues_with_query(query).map(&:id).sort + end + + def test_filter_on_relations_with_no_issues + IssueRelation.delete_all + IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Issue.find(2)) + IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(3), :issue_to => Issue.find(1)) + + query = Query.new(:name => '_') + query.filters = {"relates" => {:operator => '!*', :values => ['']}} + ids = find_issues_with_query(query).map(&:id) + assert_equal [], ids & [1, 2, 3] + assert_include 4, ids + end + + def test_filter_on_relations_with_any_issue + IssueRelation.delete_all + IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Issue.find(2)) + IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(3), :issue_to => Issue.find(1)) + + query = Query.new(:name => '_') + query.filters = {"relates" => {:operator => '*', :values => ['']}} + assert_equal [1, 2, 3], find_issues_with_query(query).map(&:id) + end + def test_statement_should_be_nil_with_no_filters q = Query.new(:name => '_') q.filters = {}