Makes related issues available for display and filtering on the issue list (#3239, #3265).

git-svn-id: svn+ssh://rubyforge.org/var/svn/redmine/trunk@10513 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
Jean-Philippe Lang 2012-09-29 12:57:38 +00:00
parent 69b8931e92
commit 1b6da80e16
12 changed files with 271 additions and 37 deletions

View File

@ -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

View File

@ -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

View File

@ -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])

View File

@ -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

View File

@ -113,7 +113,9 @@ class Query < ActiveRecord::Base
"<t-" => :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)

View File

@ -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| %>

View File

@ -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

View File

@ -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

View File

@ -178,6 +178,20 @@ function buildFilterRow(field, operator, values) {
);
$('#values_'+fieldId).val(values[0]);
break;
case "relation":
tr.find('td.values').append(
'<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="6" class="value" /></span>' +
'<span style="display:none;"><select class="value" name="v['+field+'][]" id="values_'+fieldId+'_1"></select></span>'
);
$('#values_'+fieldId).val(values[0]);
select = tr.find('td.values select');
for (i=0;i<allProjects.length;i++){
var filterValue = allProjects[i];
var option = $('<option>');
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;

View File

@ -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; }

View File

@ -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

View File

@ -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 = {}