##// END OF EJS Templates
Adds subtasking (#443) including:...
Jean-Philippe Lang -
r3459:8e3d1b694ab4
parent child
Show More
@@ -0,0 +1,9
1 <ul>
2 <% if @issues.any? -%>
3 <% @issues.each do |issue| -%>
4 <%= content_tag 'li', h("#{issue.tracker} ##{issue.id}: #{issue.subject}"), :id => issue.id %>
5 <% end -%>
6 <% else -%>
7 <%= content_tag("li", l(:label_none), :style => 'display:none') %>
8 <% end -%>
9 </ul>
@@ -0,0 +1,17
1 class AddIssuesNestedSetsColumns < ActiveRecord::Migration
2 def self.up
3 add_column :issues, :parent_id, :integer, :default => nil
4 add_column :issues, :root_id, :integer, :default => nil
5 add_column :issues, :lft, :integer, :default => nil
6 add_column :issues, :rgt, :integer, :default => nil
7
8 Issue.update_all("parent_id = NULL, root_id = id, lft = 1, rgt = 2")
9 end
10
11 def self.down
12 remove_column :issues, :parent_id
13 remove_column :issues, :root_id
14 remove_column :issues, :lft
15 remove_column :issues, :rgt
16 end
17 end
1 NO CONTENT: new file 100644, binary diff hidden
@@ -0,0 +1,300
1 # redMine - project management software
2 # Copyright (C) 2006-2007 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.dirname(__FILE__) + '/../test_helper'
19
20 class IssueNestedSetTest < ActiveSupport::TestCase
21 fixtures :projects, :users, :members, :member_roles, :roles,
22 :trackers, :projects_trackers,
23 :versions,
24 :issue_statuses, :issue_categories, :issue_relations, :workflows,
25 :enumerations,
26 :issues,
27 :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
28 :time_entries
29
30 self.use_transactional_fixtures = false
31
32 def test_create_root_issue
33 issue1 = create_issue!
34 issue2 = create_issue!
35 issue1.reload
36 issue2.reload
37
38 assert_equal [issue1.id, nil, 1, 2], [issue1.root_id, issue1.parent_id, issue1.lft, issue1.rgt]
39 assert_equal [issue2.id, nil, 1, 2], [issue2.root_id, issue2.parent_id, issue2.lft, issue2.rgt]
40 end
41
42 def test_create_child_issue
43 parent = create_issue!
44 child = create_issue!(:parent_issue_id => parent.id)
45 parent.reload
46 child.reload
47
48 assert_equal [parent.id, nil, 1, 4], [parent.root_id, parent.parent_id, parent.lft, parent.rgt]
49 assert_equal [parent.id, parent.id, 2, 3], [child.root_id, child.parent_id, child.lft, child.rgt]
50 end
51
52 def test_creating_a_child_in_different_project_should_not_validate
53 issue = create_issue!
54 child = Issue.new(:project_id => 2, :tracker_id => 1, :author_id => 1, :subject => 'child', :parent_issue_id => issue.id)
55 assert !child.save
56 assert_not_nil child.errors.on(:parent_issue_id)
57 end
58
59 def test_move_a_root_to_child
60 parent1 = create_issue!
61 parent2 = create_issue!
62 child = create_issue!(:parent_issue_id => parent1.id)
63
64 parent2.parent_issue_id = parent1.id
65 parent2.save!
66 child.reload
67 parent1.reload
68 parent2.reload
69
70 assert_equal [parent1.id, 1, 6], [parent1.root_id, parent1.lft, parent1.rgt]
71 assert_equal [parent1.id, 4, 5], [parent2.root_id, parent2.lft, parent2.rgt]
72 assert_equal [parent1.id, 2, 3], [child.root_id, child.lft, child.rgt]
73 end
74
75 def test_move_a_child_to_root
76 parent1 = create_issue!
77 parent2 = create_issue!
78 child = create_issue!(:parent_issue_id => parent1.id)
79
80 child.parent_issue_id = nil
81 child.save!
82 child.reload
83 parent1.reload
84 parent2.reload
85
86 assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
87 assert_equal [parent2.id, 1, 2], [parent2.root_id, parent2.lft, parent2.rgt]
88 assert_equal [child.id, 1, 2], [child.root_id, child.lft, child.rgt]
89 end
90
91 def test_move_a_child_to_another_issue
92 parent1 = create_issue!
93 parent2 = create_issue!
94 child = create_issue!(:parent_issue_id => parent1.id)
95
96 child.parent_issue_id = parent2.id
97 child.save!
98 child.reload
99 parent1.reload
100 parent2.reload
101
102 assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
103 assert_equal [parent2.id, 1, 4], [parent2.root_id, parent2.lft, parent2.rgt]
104 assert_equal [parent2.id, 2, 3], [child.root_id, child.lft, child.rgt]
105 end
106
107 def test_move_a_child_with_descendants_to_another_issue
108 parent1 = create_issue!
109 parent2 = create_issue!
110 child = create_issue!(:parent_issue_id => parent1.id)
111 grandchild = create_issue!(:parent_issue_id => child.id)
112
113 parent1.reload
114 parent2.reload
115 child.reload
116 grandchild.reload
117
118 assert_equal [parent1.id, 1, 6], [parent1.root_id, parent1.lft, parent1.rgt]
119 assert_equal [parent2.id, 1, 2], [parent2.root_id, parent2.lft, parent2.rgt]
120 assert_equal [parent1.id, 2, 5], [child.root_id, child.lft, child.rgt]
121 assert_equal [parent1.id, 3, 4], [grandchild.root_id, grandchild.lft, grandchild.rgt]
122
123 child.reload.parent_issue_id = parent2.id
124 child.save!
125 child.reload
126 grandchild.reload
127 parent1.reload
128 parent2.reload
129
130 assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
131 assert_equal [parent2.id, 1, 6], [parent2.root_id, parent2.lft, parent2.rgt]
132 assert_equal [parent2.id, 2, 5], [child.root_id, child.lft, child.rgt]
133 assert_equal [parent2.id, 3, 4], [grandchild.root_id, grandchild.lft, grandchild.rgt]
134 end
135
136 def test_move_a_child_with_descendants_to_another_project
137 parent1 = create_issue!
138 child = create_issue!(:parent_issue_id => parent1.id)
139 grandchild = create_issue!(:parent_issue_id => child.id)
140
141 assert child.reload.move_to_project(Project.find(2))
142 child.reload
143 grandchild.reload
144 parent1.reload
145
146 assert_equal [1, parent1.id, 1, 2], [parent1.project_id, parent1.root_id, parent1.lft, parent1.rgt]
147 assert_equal [2, child.id, 1, 4], [child.project_id, child.root_id, child.lft, child.rgt]
148 assert_equal [2, child.id, 2, 3], [grandchild.project_id, grandchild.root_id, grandchild.lft, grandchild.rgt]
149 end
150
151 def test_invalid_move_to_another_project
152 parent1 = create_issue!
153 child = create_issue!(:parent_issue_id => parent1.id)
154 grandchild = create_issue!(:parent_issue_id => child.id, :tracker_id => 2)
155 Project.find(2).tracker_ids = [1]
156
157 parent1.reload
158 assert_equal [1, parent1.id, 1, 6], [parent1.project_id, parent1.root_id, parent1.lft, parent1.rgt]
159
160 # child can not be moved to Project 2 because its child is on a disabled tracker
161 assert_equal false, Issue.find(child.id).move_to_project(Project.find(2))
162 child.reload
163 grandchild.reload
164 parent1.reload
165
166 # no change
167 assert_equal [1, parent1.id, 1, 6], [parent1.project_id, parent1.root_id, parent1.lft, parent1.rgt]
168 assert_equal [1, parent1.id, 2, 5], [child.project_id, child.root_id, child.lft, child.rgt]
169 assert_equal [1, parent1.id, 3, 4], [grandchild.project_id, grandchild.root_id, grandchild.lft, grandchild.rgt]
170 end
171
172 def test_moving_an_issue_to_a_descendant_should_not_validate
173 parent1 = create_issue!
174 parent2 = create_issue!
175 child = create_issue!(:parent_issue_id => parent1.id)
176 grandchild = create_issue!(:parent_issue_id => child.id)
177
178 child.reload
179 child.parent_issue_id = grandchild.id
180 assert !child.save
181 assert_not_nil child.errors.on(:parent_issue_id)
182 end
183
184 def test_moving_an_issue_should_keep_valid_relations_only
185 issue1 = create_issue!
186 issue2 = create_issue!
187 issue3 = create_issue!(:parent_issue_id => issue2.id)
188 issue4 = create_issue!
189 r1 = IssueRelation.create!(:issue_from => issue1, :issue_to => issue2, :relation_type => IssueRelation::TYPE_PRECEDES)
190 r2 = IssueRelation.create!(:issue_from => issue1, :issue_to => issue3, :relation_type => IssueRelation::TYPE_PRECEDES)
191 r3 = IssueRelation.create!(:issue_from => issue2, :issue_to => issue4, :relation_type => IssueRelation::TYPE_PRECEDES)
192 issue2.reload
193 issue2.parent_issue_id = issue1.id
194 issue2.save!
195 assert !IssueRelation.exists?(r1.id)
196 assert !IssueRelation.exists?(r2.id)
197 assert IssueRelation.exists?(r3.id)
198 end
199
200 def test_destroy_should_destroy_children
201 issue1 = create_issue!
202 issue2 = create_issue!
203 issue3 = create_issue!(:parent_issue_id => issue2.id)
204 issue4 = create_issue!(:parent_issue_id => issue1.id)
205 issue2.reload.destroy
206 issue1.reload
207 issue4.reload
208 assert !Issue.exists?(issue2.id)
209 assert !Issue.exists?(issue3.id)
210 assert_equal [issue1.id, 1, 4], [issue1.root_id, issue1.lft, issue1.rgt]
211 assert_equal [issue1.id, 2, 3], [issue4.root_id, issue4.lft, issue4.rgt]
212 end
213
214 def test_parent_priority_should_be_the_highest_child_priority
215 parent = create_issue!(:priority => IssuePriority.find_by_name('Normal'))
216 # Create children
217 child1 = create_issue!(:priority => IssuePriority.find_by_name('High'), :parent_issue_id => parent.id)
218 assert_equal 'High', parent.reload.priority.name
219 child2 = create_issue!(:priority => IssuePriority.find_by_name('Immediate'), :parent_issue_id => child1.id)
220 assert_equal 'Immediate', child1.reload.priority.name
221 assert_equal 'Immediate', parent.reload.priority.name
222 child3 = create_issue!(:priority => IssuePriority.find_by_name('Low'), :parent_issue_id => parent.id)
223 assert_equal 'Immediate', parent.reload.priority.name
224 # Destroy a child
225 child1.destroy
226 assert_equal 'Low', parent.reload.priority.name
227 # Update a child
228 child3.reload.priority = IssuePriority.find_by_name('Normal')
229 child3.save!
230 assert_equal 'Normal', parent.reload.priority.name
231 end
232
233 def test_parent_dates_should_be_lowest_start_and_highest_due_dates
234 parent = create_issue!
235 create_issue!(:start_date => '2010-01-25', :due_date => '2010-02-15', :parent_issue_id => parent.id)
236 create_issue!( :due_date => '2010-02-13', :parent_issue_id => parent.id)
237 create_issue!(:start_date => '2010-02-01', :due_date => '2010-02-22', :parent_issue_id => parent.id)
238 parent.reload
239 assert_equal Date.parse('2010-01-25'), parent.start_date
240 assert_equal Date.parse('2010-02-22'), parent.due_date
241 end
242
243 def test_parent_done_ratio_should_be_average_done_ratio_of_leaves
244 parent = create_issue!
245 create_issue!(:done_ratio => 20, :parent_issue_id => parent.id)
246 assert_equal 20, parent.reload.done_ratio
247 create_issue!(:done_ratio => 70, :parent_issue_id => parent.id)
248 assert_equal 45, parent.reload.done_ratio
249
250 child = create_issue!(:done_ratio => 0, :parent_issue_id => parent.id)
251 assert_equal 30, parent.reload.done_ratio
252
253 create_issue!(:done_ratio => 30, :parent_issue_id => child.id)
254 assert_equal 30, child.reload.done_ratio
255 assert_equal 40, parent.reload.done_ratio
256 end
257
258 def test_parent_done_ratio_should_be_weighted_by_estimated_times_if_any
259 parent = create_issue!
260 create_issue!(:estimated_hours => 10, :done_ratio => 20, :parent_issue_id => parent.id)
261 assert_equal 20, parent.reload.done_ratio
262 create_issue!(:estimated_hours => 20, :done_ratio => 50, :parent_issue_id => parent.id)
263 assert_equal (50 * 20 + 20 * 10) / 30, parent.reload.done_ratio
264 end
265
266 def test_parent_estimate_should_be_sum_of_leaves
267 parent = create_issue!
268 create_issue!(:estimated_hours => nil, :parent_issue_id => parent.id)
269 assert_equal nil, parent.reload.estimated_hours
270 create_issue!(:estimated_hours => 5, :parent_issue_id => parent.id)
271 assert_equal 5, parent.reload.estimated_hours
272 create_issue!(:estimated_hours => 7, :parent_issue_id => parent.id)
273 assert_equal 12, parent.reload.estimated_hours
274 end
275
276 def test_project_copy_should_copy_issue_tree
277 p = Project.create!(:name => 'Tree copy', :identifier => 'tree-copy', :tracker_ids => [1, 2])
278 i1 = create_issue!(:project_id => p.id, :subject => 'i1')
279 i2 = create_issue!(:project_id => p.id, :subject => 'i2', :parent_issue_id => i1.id)
280 i3 = create_issue!(:project_id => p.id, :subject => 'i3', :parent_issue_id => i1.id)
281 i4 = create_issue!(:project_id => p.id, :subject => 'i4', :parent_issue_id => i2.id)
282 i5 = create_issue!(:project_id => p.id, :subject => 'i5')
283 c = Project.new(:name => 'Copy', :identifier => 'copy', :tracker_ids => [1, 2])
284 c.copy(p, :only => 'issues')
285 c.reload
286
287 assert_equal 5, c.issues.count
288 ic1, ic2, ic3, ic4, ic5 = c.issues.find(:all, :order => 'subject')
289 assert ic1.root?
290 assert_equal ic1, ic2.parent
291 assert_equal ic1, ic3.parent
292 assert_equal ic2, ic4.parent
293 assert ic5.root?
294 end
295
296 # Helper that creates an issue with default attributes
297 def create_issue!(attributes={})
298 Issue.create!({:project_id => 1, :tracker_id => 1, :author_id => 1, :subject => 'test'}.merge(attributes))
299 end
300 end
@@ -21,7 +21,7 class IssuesController < ApplicationController
21 21
22 22 before_filter :find_issue, :only => [:show, :edit, :update, :reply]
23 23 before_filter :find_issues, :only => [:bulk_edit, :move, :destroy]
24 before_filter :find_project, :only => [:new, :update_form, :preview]
24 before_filter :find_project, :only => [:new, :update_form, :preview, :auto_complete]
25 25 before_filter :authorize, :except => [:index, :changes, :gantt, :calendar, :preview, :context_menu]
26 26 before_filter :find_optional_project, :only => [:index, :changes, :gantt, :calendar]
27 27 accept_key_auth :index, :show, :changes
@@ -164,7 +164,8 class IssuesController < ApplicationController
164 164 call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
165 165 respond_to do |format|
166 166 format.html {
167 redirect_to(params[:continue] ? { :action => 'new', :tracker_id => @issue.tracker } :
167 redirect_to(params[:continue] ? { :action => 'new', :issue => {:tracker_id => @issue.tracker,
168 :parent_issue_id => @issue.parent_issue_id}.reject {|k,v| v.nil?} } :
168 169 { :action => 'show', :id => @issue })
169 170 }
170 171 format.xml { render :action => 'show', :status => :created, :location => url_for(:controller => 'issues', :action => 'show', :id => @issue) }
@@ -247,6 +248,7 class IssuesController < ApplicationController
247 248
248 249 # Bulk edit a set of issues
249 250 def bulk_edit
251 @issues.sort!
250 252 if request.post?
251 253 attributes = (params[:issue] || {}).reject {|k,v| v.blank?}
252 254 attributes.keys.each {|k| attributes[k] = '' if attributes[k] == 'none'}
@@ -254,6 +256,7 class IssuesController < ApplicationController
254 256
255 257 unsaved_issue_ids = []
256 258 @issues.each do |issue|
259 issue.reload
257 260 journal = issue.init_journal(User.current, params[:notes])
258 261 issue.safe_attributes = attributes
259 262 call_hook(:controller_issues_bulk_edit_before_save, { :params => params, :issue => issue })
@@ -271,6 +274,7 class IssuesController < ApplicationController
271 274 end
272 275
273 276 def move
277 @issues.sort!
274 278 @copy = params[:copy_options] && params[:copy_options][:copy]
275 279 @allowed_projects = []
276 280 # find projects to which the user is allowed to move the issue
@@ -289,6 +293,7 class IssuesController < ApplicationController
289 293 unsaved_issue_ids = []
290 294 moved_issues = []
291 295 @issues.each do |issue|
296 issue.reload
292 297 changed_attributes = {}
293 298 [:assigned_to_id, :status_id, :start_date, :due_date].each do |valid_attribute|
294 299 unless params[valid_attribute].blank?
@@ -297,7 +302,7 class IssuesController < ApplicationController
297 302 end
298 303 issue.init_journal(User.current)
299 304 call_hook(:controller_issues_move_before_save, { :params => params, :issue => issue, :target_project => @target_project, :copy => !!@copy })
300 if r = issue.move_to(@target_project, new_tracker, {:copy => @copy, :attributes => changed_attributes})
305 if r = issue.move_to_project(@target_project, new_tracker, {:copy => @copy, :attributes => changed_attributes})
301 306 moved_issues << r
302 307 else
303 308 unsaved_issue_ids << issue.id
@@ -456,6 +461,18 class IssuesController < ApplicationController
456 461 render :partial => 'common/preview'
457 462 end
458 463
464 def auto_complete
465 @issues = []
466 q = params[:q].to_s
467 if q.match(/^\d+$/)
468 @issues << @project.issues.visible.find_by_id(q.to_i)
469 end
470 unless q.blank?
471 @issues += @project.issues.visible.find(:all, :conditions => ["LOWER(#{Issue.table_name}.subject) LIKE ?", "%#{q.downcase}%"], :limit => 10)
472 end
473 render :layout => false
474 end
475
459 476 private
460 477 def find_issue
461 478 @issue = Issue.find(params[:id], :include => [:project, :tracker, :status, :author, :priority, :category])
@@ -94,7 +94,7 class TimelogController < ApplicationController
94 94 elsif @issue.nil?
95 95 sql_condition = @project.project_condition(Setting.display_subprojects_issues?)
96 96 else
97 sql_condition = "#{TimeEntry.table_name}.issue_id = #{@issue.id}"
97 sql_condition = "#{Issue.table_name}.root_id = #{@issue.root_id} AND #{Issue.table_name}.lft >= #{@issue.lft} AND #{Issue.table_name}.rgt <= #{@issue.rgt}"
98 98 end
99 99
100 100 sql = "SELECT #{sql_select}, tyear, tmonth, tweek, spent_on, SUM(hours) AS hours"
@@ -166,7 +166,7 class TimelogController < ApplicationController
166 166 elsif @issue.nil?
167 167 cond << @project.project_condition(Setting.display_subprojects_issues?)
168 168 else
169 cond << ["#{TimeEntry.table_name}.issue_id = ?", @issue.id]
169 cond << "#{Issue.table_name}.root_id = #{@issue.root_id} AND #{Issue.table_name}.lft >= #{@issue.lft} AND #{Issue.table_name}.rgt <= #{@issue.rgt}"
170 170 end
171 171
172 172 retrieve_date_range
@@ -176,7 +176,7 class TimelogController < ApplicationController
176 176 respond_to do |format|
177 177 format.html {
178 178 # Paginate results
179 @entry_count = TimeEntry.count(:include => :project, :conditions => cond.conditions)
179 @entry_count = TimeEntry.count(:include => [:project, :issue], :conditions => cond.conditions)
180 180 @entry_pages = Paginator.new self, @entry_count, per_page_option, params['page']
181 181 @entries = TimeEntry.find(:all,
182 182 :include => [:project, :activity, :user, {:issue => :tracker}],
@@ -184,7 +184,7 class TimelogController < ApplicationController
184 184 :order => sort_clause,
185 185 :limit => @entry_pages.items_per_page,
186 186 :offset => @entry_pages.current.offset)
187 @total_hours = TimeEntry.sum(:hours, :include => :project, :conditions => cond.conditions).to_f
187 @total_hours = TimeEntry.sum(:hours, :include => [:project, :issue], :conditions => cond.conditions).to_f
188 188
189 189 render :layout => !request.xhr?
190 190 }
@@ -31,6 +31,34 module IssuesHelper
31 31 "<strong>#{@cached_label_priority}</strong>: #{issue.priority.name}"
32 32 end
33 33
34 def render_issue_subject_with_tree(issue)
35 s = ''
36 issue.ancestors.each do |ancestor|
37 s << '<div>' + content_tag('p', link_to_issue(ancestor))
38 end
39 s << '<div>' + content_tag('h3', h(issue.subject))
40 s << '</div>' * (issue.ancestors.size + 1)
41 s
42 end
43
44 def render_descendants_tree(issue)
45 s = '<form><table class="list issues">'
46 ancestors = []
47 issue.descendants.sort_by(&:lft).each do |child|
48 level = child.level - issue.level - 1
49 s << content_tag('tr',
50 content_tag('td', check_box_tag("ids[]", child.id, false, :id => nil)) +
51 content_tag('td', link_to_issue(child), :class => 'subject',
52 :style => "padding-left: #{level * 20}px") +
53 content_tag('td', h(child.status)) +
54 content_tag('td', link_to_user(child.assigned_to)) +
55 content_tag('td', progress_bar(child.done_ratio, :width => '80px')),
56 :class => "issue-#{child.id} hascontextmenu")
57 end
58 s << '</form></table>'
59 s
60 end
61
34 62 def render_custom_fields_rows(issue)
35 63 return if issue.custom_field_values.empty?
36 64 ordered_values = []
@@ -32,6 +32,7 class Issue < ActiveRecord::Base
32 32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
33 33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
34 34
35 acts_as_nested_set :scope => 'root_id'
35 36 acts_as_attachable :after_remove => :attachment_removed
36 37 acts_as_customizable
37 38 acts_as_watchable
@@ -68,7 +69,9 class Issue < ActiveRecord::Base
68 69
69 70 before_create :default_assign
70 71 before_save :reschedule_following_issues, :close_duplicates, :update_done_ratio_from_issue_status
71 after_save :create_journal
72 after_save :update_nested_set_attributes, :update_parent_attributes, :create_journal
73 after_destroy :destroy_children
74 after_destroy :update_parent_attributes
72 75
73 76 # Returns true if usr or current user is allowed to view the issue
74 77 def visible?(usr=nil)
@@ -90,18 +93,24 class Issue < ActiveRecord::Base
90 93
91 94 def copy_from(arg)
92 95 issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
93 self.attributes = issue.attributes.dup.except("id", "created_on", "updated_on")
94 self.custom_values = issue.custom_values.collect {|v| v.clone}
96 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
97 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
95 98 self.status = issue.status
96 99 self
97 100 end
98 101
99 102 # Moves/copies an issue to a new project and tracker
100 103 # Returns the moved/copied issue on success, false on failure
101 def move_to(new_project, new_tracker = nil, options = {})
102 options ||= {}
103 issue = options[:copy] ? self.clone : self
104 def move_to_project(*args)
104 105 ret = Issue.transaction do
106 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
107 end || false
108 end
109
110 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
111 options ||= {}
112 issue = options[:copy] ? self.class.new.copy_from(self) : self
113
105 114 if new_project && issue.project_id != new_project.id
106 115 # delete issue relations
107 116 unless Setting.cross_project_issue_relations?
@@ -117,9 +126,13 class Issue < ActiveRecord::Base
117 126 issue.fixed_version = nil
118 127 end
119 128 issue.project = new_project
129 if issue.parent && issue.parent.project_id != issue.project_id
130 issue.parent_issue_id = nil
131 end
120 132 end
121 133 if new_tracker
122 134 issue.tracker = new_tracker
135 issue.reset_custom_values!
123 136 end
124 137 if options[:copy]
125 138 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
@@ -137,13 +150,18 class Issue < ActiveRecord::Base
137 150 unless options[:copy]
138 151 # Manually update project_id on related time entries
139 152 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
153
154 issue.children.each do |child|
155 unless child.move_to_project_without_transaction(new_project)
156 # Move failed and transaction was rollback'd
157 return false
140 158 end
141 true
142 else
143 raise ActiveRecord::Rollback
144 159 end
145 160 end
146 ret ? issue : false
161 else
162 return false
163 end
164 issue
147 165 end
148 166
149 167 def priority_id=(pid)
@@ -177,6 +195,7 class Issue < ActiveRecord::Base
177 195 SAFE_ATTRIBUTES = %w(
178 196 tracker_id
179 197 status_id
198 parent_issue_id
180 199 category_id
181 200 assigned_to_id
182 201 priority_id
@@ -203,6 +222,19 class Issue < ActiveRecord::Base
203 222 attrs.delete('status_id')
204 223 end
205 224 end
225
226 unless leaf?
227 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
228 end
229
230 if attrs.has_key?('parent_issue_id')
231 if !user.allowed_to?(:manage_subtasks, project)
232 attrs.delete('parent_issue_id')
233 elsif !attrs['parent_issue_id'].blank?
234 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'])
235 end
236 end
237
206 238 self.attributes = attrs
207 239 end
208 240
@@ -249,6 +281,22 class Issue < ActiveRecord::Base
249 281 errors.add :tracker_id, :inclusion
250 282 end
251 283 end
284
285 # Checks parent issue assignment
286 if @parent_issue
287 if @parent_issue.project_id != project_id
288 errors.add :parent_issue_id, :not_same_project
289 elsif !new_record?
290 # moving an existing issue
291 if @parent_issue.root_id != root_id
292 # we can always move to another tree
293 elsif move_possible?(@parent_issue)
294 # move accepted inside tree
295 else
296 errors.add :parent_issue_id, :not_a_valid_parent
297 end
298 end
299 end
252 300 end
253 301
254 302 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
@@ -340,13 +388,13 class Issue < ActiveRecord::Base
340 388 notified.collect(&:mail)
341 389 end
342 390
343 # Returns the total number of hours spent on this issue.
391 # Returns the total number of hours spent on this issue and its descendants
344 392 #
345 393 # Example:
346 # spent_hours => 0
347 # spent_hours => 50
394 # spent_hours => 0.0
395 # spent_hours => 50.2
348 396 def spent_hours
349 @spent_hours ||= time_entries.sum(:hours) || 0
397 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
350 398 end
351 399
352 400 def relations
@@ -386,6 +434,16 class Issue < ActiveRecord::Base
386 434 @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min
387 435 end
388 436
437 def <=>(issue)
438 if issue.nil?
439 -1
440 elsif root_id != issue.root_id
441 (root_id || 0) <=> (issue.root_id || 0)
442 else
443 (lft || 0) <=> (issue.lft || 0)
444 end
445 end
446
389 447 def to_s
390 448 "#{tracker} ##{id}: #{subject}"
391 449 end
@@ -442,6 +500,24 class Issue < ActiveRecord::Base
442 500 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
443 501 end
444 502
503 def parent_issue_id=(arg)
504 parent_issue_id = arg.blank? ? nil : arg.to_i
505 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
506 @parent_issue.id
507 else
508 @parent_issue = nil
509 nil
510 end
511 end
512
513 def parent_issue_id
514 if instance_variable_defined? :@parent_issue
515 @parent_issue.nil? ? nil : @parent_issue.id
516 else
517 parent_id
518 end
519 end
520
445 521 # Extracted from the ReportsController.
446 522 def self.by_tracker(project)
447 523 count_and_group_by(:project => project,
@@ -495,6 +571,95 class Issue < ActiveRecord::Base
495 571
496 572 private
497 573
574 def update_nested_set_attributes
575 if root_id.nil?
576 # issue was just created
577 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
578 set_default_left_and_right
579 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
580 if @parent_issue
581 move_to_child_of(@parent_issue)
582 end
583 reload
584 elsif parent_issue_id != parent_id
585 # moving an existing issue
586 if @parent_issue && @parent_issue.root_id == root_id
587 # inside the same tree
588 move_to_child_of(@parent_issue)
589 else
590 # to another tree
591 unless root?
592 move_to_right_of(root)
593 reload
594 end
595 old_root_id = root_id
596 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
597 target_maxright = nested_set_scope.maximum(right_column_name) || 0
598 offset = target_maxright + 1 - lft
599 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
600 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
601 self[left_column_name] = lft + offset
602 self[right_column_name] = rgt + offset
603 if @parent_issue
604 move_to_child_of(@parent_issue)
605 end
606 end
607 reload
608 # delete invalid relations of all descendants
609 self_and_descendants.each do |issue|
610 issue.relations.each do |relation|
611 relation.destroy unless relation.valid?
612 end
613 end
614 end
615 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
616 end
617
618 def update_parent_attributes
619 if parent_id && p = Issue.find_by_id(parent_id)
620 # priority = highest priority of children
621 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
622 p.priority = IssuePriority.find_by_position(priority_position)
623 end
624
625 # start/due dates = lowest/highest dates of children
626 p.start_date = p.children.minimum(:start_date)
627 p.due_date = p.children.maximum(:due_date)
628 if p.start_date && p.due_date && p.due_date < p.start_date
629 p.start_date, p.due_date = p.due_date, p.start_date
630 end
631
632 # done ratio = weighted average ratio of leaves
633 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio?
634 leaves_count = p.leaves.count
635 if leaves_count > 0
636 average = p.leaves.average(:estimated_hours).to_f
637 if average == 0
638 average = 1
639 end
640 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
641 progress = done / (average * leaves_count)
642 p.done_ratio = progress.round
643 end
644 end
645
646 # estimate = sum of leaves estimates
647 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
648 p.estimated_hours = nil if p.estimated_hours == 0.0
649
650 # ancestors will be recursively updated
651 p.save(false)
652 end
653 end
654
655 def destroy_children
656 unless leaf?
657 children.each do |child|
658 child.destroy
659 end
660 end
661 end
662
498 663 # Update issues so their versions are not pointing to a
499 664 # fixed_version that is not shared with the issue's project
500 665 def self.update_versions(conditions=nil)
@@ -562,7 +727,7 class Issue < ActiveRecord::Base
562 727 def create_journal
563 728 if @current_journal
564 729 # attributes changes
565 (Issue.column_names - %w(id description lock_version created_on updated_on)).each {|c|
730 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
566 731 @current_journal.details << JournalDetail.new(:property => 'attr',
567 732 :prop_key => c,
568 733 :old_value => @issue_before_change.send(c),
@@ -48,6 +48,7 class IssueRelation < ActiveRecord::Base
48 48 errors.add :issue_to_id, :invalid if issue_from_id == issue_to_id
49 49 errors.add :issue_to_id, :not_same_project unless issue_from.project_id == issue_to.project_id || Setting.cross_project_issue_relations?
50 50 errors.add_to_base :circular_dependency if issue_to.all_dependent_issues.include? issue_from
51 errors.add_to_base :cant_link_an_issue_with_a_descendant if issue_from.is_descendant_of?(issue_to) || issue_from.is_ancestor_of?(issue_to)
51 52 end
52 53 end
53 54
@@ -557,9 +557,12 class Project < ActiveRecord::Base
557 557 # value. Used to map the two togeather for issue relations.
558 558 issues_map = {}
559 559
560 project.issues.each do |issue|
560 # Get issues sorted by root_id, lft so that parent issues
561 # get copied before their children
562 project.issues.find(:all, :order => 'root_id, lft').each do |issue|
561 563 new_issue = Issue.new
562 564 new_issue.copy_from(issue)
565 new_issue.project = self
563 566 # Reassign fixed_versions by name, since names are unique per
564 567 # project and the versions for self are not yet saved
565 568 if issue.fixed_version
@@ -570,6 +573,13 class Project < ActiveRecord::Base
570 573 if issue.category
571 574 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
572 575 end
576 # Parent issue
577 if issue.parent_id
578 if copied_parent = issues_map[issue.parent_id]
579 new_issue.parent_issue_id = copied_parent.id
580 end
581 end
582
573 583 self.issues << new_issue
574 584 issues_map[issue.id] = new_issue
575 585 end
@@ -7,7 +7,7
7 7 <p><label><%= l(:field_status) %></label> <%= @issue.status.name %></p>
8 8 <% end %>
9 9
10 <p><%= f.select :priority_id, (@priorities.collect {|p| [p.name, p.id]}), :required => true %></p>
10 <p><%= f.select :priority_id, (@priorities.collect {|p| [p.name, p.id]}), {:required => true}, :disabled => !@issue.leaf? %></p>
11 11 <p><%= f.select :assigned_to_id, (@issue.assignable_users.collect {|m| [m.name, m.id]}), :include_blank => true %></p>
12 12 <% unless @project.issue_categories.empty? %>
13 13 <p><%= f.select :category_id, (@project.issue_categories.collect {|c| [c.name, c.id]}), :include_blank => true %>
@@ -31,10 +31,10
31 31 </div>
32 32
33 33 <div class="splitcontentright">
34 <p><%= f.text_field :start_date, :size => 10 %><%= calendar_for('issue_start_date') %></p>
35 <p><%= f.text_field :due_date, :size => 10 %><%= calendar_for('issue_due_date') %></p>
36 <p><%= f.text_field :estimated_hours, :size => 3 %> <%= l(:field_hours) %></p>
37 <% if Issue.use_field_for_done_ratio? %>
34 <p><%= f.text_field :start_date, :size => 10, :disabled => !@issue.leaf? %><%= calendar_for('issue_start_date') if @issue.leaf? %></p>
35 <p><%= f.text_field :due_date, :size => 10, :disabled => !@issue.leaf? %><%= calendar_for('issue_due_date') if @issue.leaf? %></p>
36 <p><%= f.text_field :estimated_hours, :size => 3, :disabled => !@issue.leaf? %> <%= l(:field_hours) %></p>
37 <% if @issue.leaf? && Issue.use_field_for_done_ratio? %>
38 38 <p><%= f.select :done_ratio, ((0..10).to_a.collect {|r| ["#{r*10} %", r*10] }) %></p>
39 39 <% end %>
40 40 </div>
@@ -5,6 +5,16
5 5 :with => "Form.serialize('issue-form')" %>
6 6
7 7 <p><%= f.text_field :subject, :size => 80, :required => true %></p>
8
9 <% unless (@issue.new_record? && @issue.parent_issue_id.nil?) || !User.current.allowed_to?(:manage_subtasks, @project) %>
10 <p><%= f.text_field :parent_issue_id, :size => 10 %></p>
11 <div id="parent_issue_candidates" class="autocomplete"></div>
12 <%= javascript_tag "observeParentIssueField('#{url_for(:controller => :issues,
13 :action => :auto_complete,
14 :id => @issue,
15 :project_id => @project) }')" %>
16 <% end %>
17
8 18 <p><%= f.text_area :description,
9 19 :cols => 60,
10 20 :rows => (@issue.description.blank? ? 10 : [[10, @issue.description.length / 50].max, 100].min),
@@ -79,8 +79,10 t_height = g_height + headers_height
79 79 # Tasks subjects
80 80 #
81 81 top = headers_height + 8
82 @gantt.events.each do |i| %>
83 <div style="position: absolute;line-height:1.2em;height:16px;top:<%= top %>px;left:4px;overflow:hidden;"><small>
82 @gantt.events.each do |i|
83 left = 4 + (i.is_a?(Issue) ? i.level * 16 : 0)
84 %>
85 <div style="position: absolute;line-height:1.2em;height:16px;top:<%= top %>px;left:<%= left %>px;overflow:hidden;"><small>
84 86 <% if i.is_a? Issue %>
85 87 <%= h("#{i.project} -") unless @project && @project == i.project %>
86 88 <%= link_to_issue i %>
@@ -189,15 +191,16 top = headers_height + 10
189 191 i_width = ((i_end_date - i_start_date + 1)*zoom).floor - 2 # total width of the issue (- 2 for left and right borders)
190 192 d_width = ((i_done_date - i_start_date)*zoom).floor - 2 # done width
191 193 l_width = i_late_date ? ((i_late_date - i_start_date+1)*zoom).floor - 2 : 0 # delay width
194 css = "task " + (i.leaf? ? 'leaf' : 'parent')
192 195 %>
193 <div style="top:<%= top %>px;left:<%= i_left %>px;width:<%= i_width %>px;" class="task task_todo">&nbsp;</div>
196 <div style="top:<%= top %>px;left:<%= i_left %>px;width:<%= i_width %>px;" class="<%= css %> task_todo"><div class="left"></div>&nbsp;<div class="right"></div></div>
194 197 <% if l_width > 0 %>
195 <div style="top:<%= top %>px;left:<%= i_left %>px;width:<%= l_width %>px;" class="task task_late">&nbsp;</div>
198 <div style="top:<%= top %>px;left:<%= i_left %>px;width:<%= l_width %>px;" class="<%= css %> task_late">&nbsp;</div>
196 199 <% end %>
197 200 <% if d_width > 0 %>
198 <div style="top:<%= top %>px;left:<%= i_left %>px;width:<%= d_width %>px;" class="task task_done">&nbsp;</div>
201 <div style="top:<%= top %>px;left:<%= i_left %>px;width:<%= d_width %>px;" class="<%= css %> task_done">&nbsp;</div>
199 202 <% end %>
200 <div style="top:<%= top %>px;left:<%= i_left + i_width + 5 %>px;background:#fff;" class="task">
203 <div style="top:<%= top %>px;left:<%= i_left + i_width + 8 %>px;background:#fff;" class="<%= css %>">
201 204 <%= i.status.name %>
202 205 <%= (i.done_ratio).to_i %>%
203 206 </div>
@@ -4,7 +4,10
4 4
5 5 <div class="<%= @issue.css_classes %> details">
6 6 <%= avatar(@issue.author, :size => "50") %>
7 <h3><%=h @issue.subject %></h3>
7
8 <div class="subject">
9 <%= render_issue_subject_with_tree(@issue) %>
10 </div>
8 11 <p class="author">
9 12 <%= authoring @issue.created_on, @issue.author %>.
10 13 <% if @issue.created_on != @issue.updated_on %>
@@ -56,6 +59,17
56 59
57 60 <%= call_hook(:view_issues_show_description_bottom, :issue => @issue) %>
58 61
62 <% if !@issue.leaf? || User.current.allowed_to?(:manage_subtasks, @project) %>
63 <hr />
64 <div id="issue_tree">
65 <div class="contextual">
66 <%= link_to(l(:button_add), {:controller => 'issues', :action => 'new', :project_id => @project, :issue => {:parent_issue_id => @issue}}) if User.current.allowed_to?(:manage_subtasks, @project) %>
67 </div>
68 <p><strong><%=l(:label_subtask_pural)%></strong></p>
69 <%= render_descendants_tree(@issue) unless @issue.leaf? %>
70 </div>
71 <% end %>
72
59 73 <% if authorize_for('issue_relations', 'new') || @issue.relations.any? %>
60 74 <hr />
61 75 <div id="relations">
@@ -113,4 +127,8
113 127 <% content_for :header_tags do %>
114 128 <%= auto_discovery_link_tag(:atom, {:format => 'atom', :key => User.current.rss_key}, :title => "#{@issue.project} - #{@issue.tracker} ##{@issue.id}: #{@issue.subject}") %>
115 129 <%= stylesheet_link_tag 'scm' %>
130 <%= javascript_include_tag 'context_menu' %>
131 <%= stylesheet_link_tag 'context_menu' %>
116 132 <% end %>
133 <div id="context-menu" style="display: none;"></div>
134 <%= javascript_tag "new ContextMenu('#{url_for(:controller => 'issues', :action => 'context_menu')}')" %> No newline at end of file
@@ -112,6 +112,7 en:
112 112 greater_than_start_date: "must be greater than start date"
113 113 not_same_project: "doesn't belong to the same project"
114 114 circular_dependency: "This relation would create a circular dependency"
115 cant_link_an_issue_with_a_descendant: "An issue can not be linked to one of its subtasks"
115 116
116 117 actionview_instancetag_blank_option: Please select
117 118
@@ -277,6 +278,7 en:
277 278 field_content: Content
278 279 field_group_by: Group results by
279 280 field_sharing: Sharing
281 field_parent_issue: Parent task
280 282
281 283 setting_app_title: Application title
282 284 setting_app_subtitle: Application subtitle
@@ -386,6 +388,7 en:
386 388 permission_delete_messages: Delete messages
387 389 permission_delete_own_messages: Delete own messages
388 390 permission_export_wiki_pages: Export wiki pages
391 permission_manage_subtasks: Manage subtasks
389 392
390 393 project_module_issue_tracking: Issue tracking
391 394 project_module_time_tracking: Time tracking
@@ -750,6 +753,7 en:
750 753 label_missing_api_access_key: Missing an API access key
751 754 label_api_access_key_created_on: "API access key created {{value}} ago"
752 755 label_profile: Profile
756 label_subtask_plural: Subtasks
753 757
754 758 button_login: Login
755 759 button_submit: Submit
@@ -136,6 +136,7 fr:
136 136 greater_than_start_date: "doit être postérieure à la date de début"
137 137 not_same_project: "n'appartient pas au même projet"
138 138 circular_dependency: "Cette relation créerait une dépendance circulaire"
139 cant_link_an_issue_with_a_descendant: "Une demande ne peut pas être liée à l'une de ses sous-tâches"
139 140
140 141 actionview_instancetag_blank_option: Choisir
141 142
@@ -299,6 +300,7 fr:
299 300 field_group_by: Grouper par
300 301 field_sharing: Partage
301 302 field_active: Actif
303 field_parent_issue: Tâche parente
302 304
303 305 setting_app_title: Titre de l'application
304 306 setting_app_subtitle: Sous-titre de l'application
@@ -408,6 +410,7 fr:
408 410 permission_delete_own_messages: Supprimer ses propres messages
409 411 permission_export_wiki_pages: Exporter les pages
410 412 permission_manage_project_activities: Gérer les activités
413 permission_manage_subtasks: Gérer les sous-tâches
411 414
412 415 project_module_issue_tracking: Suivi des demandes
413 416 project_module_time_tracking: Suivi du temps passé
@@ -765,6 +768,7 fr:
765 768 label_close_versions: Fermer les versions terminées
766 769 label_revision_id: Revision {{value}}
767 770 label_profile: Profil
771 label_subtask_pural: Sous-tâches
768 772
769 773 button_login: Connexion
770 774 button_submit: Soumettre
@@ -47,13 +47,14 Redmine::AccessControl.map do |map|
47 47 map.permission :manage_categories, {:projects => :settings, :issue_categories => [:new, :edit, :destroy]}, :require => :member
48 48 # Issues
49 49 map.permission :view_issues, {:projects => :roadmap,
50 :issues => [:index, :changes, :show, :context_menu],
50 :issues => [:index, :changes, :show, :context_menu, :auto_complete],
51 51 :versions => [:show, :status_by],
52 52 :queries => :index,
53 53 :reports => [:issue_report, :issue_report_details]}
54 54 map.permission :add_issues, {:issues => [:new, :update_form]}
55 55 map.permission :edit_issues, {:issues => [:edit, :update, :reply, :bulk_edit, :update_form]}
56 56 map.permission :manage_issue_relations, {:issue_relations => [:new, :destroy]}
57 map.permission :manage_subtasks, {}
57 58 map.permission :add_issue_notes, {:issues => [:edit, :update, :reply]}
58 59 map.permission :edit_issue_notes, {:journals => :edit}, :require => :loggedin
59 60 map.permission :edit_own_issue_notes, {:journals => :edit}, :require => :loggedin
@@ -53,6 +53,7 module Redmine
53 53 :add_issues,
54 54 :edit_issues,
55 55 :manage_issue_relations,
56 :manage_subtasks,
56 57 :add_issue_notes,
57 58 :save_queries,
58 59 :view_gantt,
@@ -52,8 +52,29 module Redmine
52 52 @date_to = (@date_from >> @months) - 1
53 53 end
54 54
55
55 56 def events=(e)
56 @events = e.sort {|x,y| x.start_date <=> y.start_date }
57 @events = e
58 # Adds all ancestors
59 root_ids = e.select {|i| i.is_a?(Issue) && i.parent_id? }.collect(&:root_id).uniq
60 if root_ids.any?
61 # Retrieves all nodes
62 parents = Issue.find_all_by_root_id(root_ids, :conditions => ["rgt - lft > 1"])
63 # Only add ancestors
64 @events += parents.select {|p| @events.detect {|i| i.is_a?(Issue) && p.is_ancestor_of?(i)}}
65 end
66 @events.uniq!
67 # Sort issues by hierarchy and start dates
68 @events.sort! {|x,y|
69 if x.is_a?(Issue) && y.is_a?(Issue)
70 gantt_issue_compare(x, y, @events)
71 else
72 gantt_start_compare(x, y)
73 end
74 }
75 # Removes issues that have no start or end date
76 @events.reject! {|i| i.is_a?(Issue) && (i.start_date.nil? || i.due_before.nil?) }
77 @events
57 78 end
58 79
59 80 def params
@@ -218,6 +239,36 module Redmine
218 239 imgl.format = format
219 240 imgl.to_blob
220 241 end if Object.const_defined?(:Magick)
242
243 private
244
245 def gantt_issue_compare(x, y, issues)
246 if x.parent_id == y.parent_id
247 gantt_start_compare(x, y)
248 elsif x.is_ancestor_of?(y)
249 -1
250 elsif y.is_ancestor_of?(x)
251 1
252 else
253 ax = issues.select {|i| i.is_a?(Issue) && i.is_ancestor_of?(x) && !i.is_ancestor_of?(y) }.sort_by(&:lft).first
254 ay = issues.select {|i| i.is_a?(Issue) && i.is_ancestor_of?(y) && !i.is_ancestor_of?(x) }.sort_by(&:lft).first
255 if ax.nil? && ay.nil?
256 gantt_start_compare(x, y)
257 else
258 gantt_issue_compare(ax || x, ay || y, issues)
259 end
260 end
261 end
262
263 def gantt_start_compare(x, y)
264 if x.start_date.nil?
265 -1
266 elsif y.start_date.nil?
267 1
268 else
269 x.start_date <=> y.start_date
270 end
271 end
221 272 end
222 273 end
223 274 end
@@ -194,6 +194,18 function randomKey(size) {
194 194 return key;
195 195 }
196 196
197 function observeParentIssueField(url) {
198 new Ajax.Autocompleter('issue_parent_issue_id',
199 'parent_issue_candidates',
200 url,
201 { minChars: 3,
202 frequency: 0.5,
203 paramName: 'q',
204 updateElement: function(value) {
205 document.getElementById('issue_parent_issue_id').value = value.id;
206 }});
207 }
208
197 209 /* shows and hides ajax indicator */
198 210 Ajax.Responders.register({
199 211 onCreate: function(){
@@ -237,6 +237,13 p.breadcrumb { font-size: 0.9em; margin: 4px 0 4px 0;}
237 237 p.subtitle { font-size: 0.9em; margin: -6px 0 12px 0; font-style: italic; }
238 238 p.footnote { font-size: 0.9em; margin-top: 0px; margin-bottom: 0px; }
239 239
240 div.issue div.subject div div { padding-left: 16px; }
241 div.issue div.subject p {margin: 0; margin-bottom: 0.1em; font-size: 90%; color: #999;}
242 div.issue div.subject>div>p { margin-top: 0.5em; }
243 div.issue div.subject h3 {margin: 0; margin-bottom: 0.1em;}
244
245 #issue_tree table.issues { border: 0; }
246
240 247 fieldset.collapsible { border-width: 1px 0 0 0; font-size: 0.9em; }
241 248 fieldset.collapsible legend { padding-left: 16px; background: url(../images/arrow_expanded.png) no-repeat 0% 40%; cursor:pointer; }
242 249 fieldset.collapsible.collapsed legend { background-image: url(../images/arrow_collapsed.png); }
@@ -588,8 +595,7 button.tab-right {
588 595 /***** Auto-complete *****/
589 596 div.autocomplete {
590 597 position:absolute;
591 width:250px;
592 background-color:white;
598 width:400px;
593 599 margin:0;
594 600 padding:0;
595 601 }
@@ -598,23 +604,26 div.autocomplete ul {
598 604 margin:0;
599 605 padding:0;
600 606 }
601 div.autocomplete ul li.selected { background-color: #ffb;}
602 607 div.autocomplete ul li {
603 608 list-style-type:none;
604 609 display:block;
605 margin:0;
610 margin:-1px 0 0 0;
606 611 padding:2px;
607 612 cursor:pointer;
608 613 font-size: 90%;
609 border-bottom: 1px solid #ccc;
614 border: 1px solid #ccc;
610 615 border-left: 1px solid #ccc;
611 616 border-right: 1px solid #ccc;
617 background-color:white;
612 618 }
619 div.autocomplete ul li.selected { background-color: #ffb;}
613 620 div.autocomplete ul li span.informal {
614 621 font-size: 80%;
615 622 color: #aaa;
616 623 }
617 624
625 #parent_issue_candidates ul li {width: 500px;}
626
618 627 /***** Diff *****/
619 628 .diff_out { background: #fcc; }
620 629 .diff_in { background: #cfc; }
@@ -741,6 +750,12 background-image:url('../images/close_hl.png');
741 750 .task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
742 751 .task_done { background:#66f url(../images/task_done.png); border: 1px solid #66f; }
743 752 .task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
753
754 .task_todo.parent { background: #888; border: 1px solid #888; height: 6px;}
755 .task_late.parent, .task_done.parent { height: 3px;}
756 .task_todo.parent .left { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -5px; left: 0px; top: -1px;}
757 .task_todo.parent .right { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-right: -5px; right: 0px; top: -1px;}
758
744 759 .milestone { background-image:url(../images/milestone.png); background-repeat: no-repeat; border: 0; }
745 760
746 761 /***** Icons *****/
@@ -19,27 +19,32 enumerations_004:
19 19 id: 4
20 20 type: IssuePriority
21 21 active: true
22 position: 1
22 23 enumerations_005:
23 24 name: Normal
24 25 id: 5
25 26 type: IssuePriority
26 27 is_default: true
27 28 active: true
29 position: 2
28 30 enumerations_006:
29 31 name: High
30 32 id: 6
31 33 type: IssuePriority
32 34 active: true
35 position: 3
33 36 enumerations_007:
34 37 name: Urgent
35 38 id: 7
36 39 type: IssuePriority
37 40 active: true
41 position: 4
38 42 enumerations_008:
39 43 name: Immediate
40 44 id: 8
41 45 type: IssuePriority
42 46 active: true
47 position: 5
43 48 enumerations_009:
44 49 name: Design
45 50 id: 9
@@ -15,6 +15,9 issues_001:
15 15 status_id: 1
16 16 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
17 17 due_date: <%= 10.day.from_now.to_date.to_s(:db) %>
18 root_id: 1
19 lft: 1
20 rgt: 2
18 21 issues_002:
19 22 created_on: 2006-07-19 21:04:21 +02:00
20 23 project_id: 1
@@ -31,6 +34,9 issues_002:
31 34 status_id: 2
32 35 start_date: <%= 2.day.ago.to_date.to_s(:db) %>
33 36 due_date:
37 root_id: 2
38 lft: 1
39 rgt: 2
34 40 issues_003:
35 41 created_on: 2006-07-19 21:07:27 +02:00
36 42 project_id: 1
@@ -47,6 +53,9 issues_003:
47 53 status_id: 1
48 54 start_date: <%= 1.day.from_now.to_date.to_s(:db) %>
49 55 due_date: <%= 40.day.ago.to_date.to_s(:db) %>
56 root_id: 3
57 lft: 1
58 rgt: 2
50 59 issues_004:
51 60 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
52 61 project_id: 2
@@ -61,6 +70,9 issues_004:
61 70 assigned_to_id: 2
62 71 author_id: 2
63 72 status_id: 1
73 root_id: 4
74 lft: 1
75 rgt: 2
64 76 issues_005:
65 77 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
66 78 project_id: 3
@@ -75,6 +87,9 issues_005:
75 87 assigned_to_id:
76 88 author_id: 2
77 89 status_id: 1
90 root_id: 5
91 lft: 1
92 rgt: 2
78 93 issues_006:
79 94 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
80 95 project_id: 5
@@ -91,6 +106,9 issues_006:
91 106 status_id: 1
92 107 start_date: <%= Date.today.to_s(:db) %>
93 108 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
109 root_id: 6
110 lft: 1
111 rgt: 2
94 112 issues_007:
95 113 created_on: <%= 10.days.ago.to_date.to_s(:db) %>
96 114 project_id: 1
@@ -108,6 +126,9 issues_007:
108 126 start_date: <%= 10.days.ago.to_s(:db) %>
109 127 due_date: <%= Date.today.to_s(:db) %>
110 128 lock_version: 0
129 root_id: 7
130 lft: 1
131 rgt: 2
111 132 issues_008:
112 133 created_on: <%= 10.days.ago.to_date.to_s(:db) %>
113 134 project_id: 1
@@ -125,6 +146,9 issues_008:
125 146 start_date:
126 147 due_date:
127 148 lock_version: 0
149 root_id: 8
150 lft: 1
151 rgt: 2
128 152 issues_009:
129 153 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
130 154 project_id: 5
@@ -141,6 +165,9 issues_009:
141 165 status_id: 1
142 166 start_date: <%= Date.today.to_s(:db) %>
143 167 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
168 root_id: 9
169 lft: 1
170 rgt: 2
144 171 issues_010:
145 172 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
146 173 project_id: 5
@@ -157,6 +184,9 issues_010:
157 184 status_id: 1
158 185 start_date: <%= Date.today.to_s(:db) %>
159 186 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
187 root_id: 10
188 lft: 1
189 rgt: 2
160 190 issues_011:
161 191 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
162 192 project_id: 1
@@ -173,6 +203,9 issues_011:
173 203 status_id: 5
174 204 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
175 205 due_date:
206 root_id: 11
207 lft: 1
208 rgt: 2
176 209 issues_012:
177 210 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
178 211 project_id: 1
@@ -189,6 +222,9 issues_012:
189 222 status_id: 5
190 223 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
191 224 due_date:
225 root_id: 12
226 lft: 1
227 rgt: 2
192 228 issues_013:
193 229 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
194 230 project_id: 3
@@ -203,3 +239,6 issues_013:
203 239 assigned_to_id:
204 240 author_id: 2
205 241 status_id: 1
242 root_id: 13
243 lft: 1
244 rgt: 2
@@ -14,6 +14,7 roles_001:
14 14 - :add_issues
15 15 - :edit_issues
16 16 - :manage_issue_relations
17 - :manage_subtasks
17 18 - :add_issue_notes
18 19 - :move_issues
19 20 - :delete_issues
@@ -66,6 +67,7 roles_002:
66 67 - :add_issues
67 68 - :edit_issues
68 69 - :manage_issue_relations
70 - :manage_subtasks
69 71 - :add_issue_notes
70 72 - :move_issues
71 73 - :delete_issues
@@ -476,7 +476,7 class IssuesControllerTest < ActionController::TestCase
476 476 :subject => 'This is first issue',
477 477 :priority_id => 5},
478 478 :continue => ''
479 assert_redirected_to :controller => 'issues', :action => 'new', :tracker_id => 3
479 assert_redirected_to :controller => 'issues', :action => 'new', :issue => {:tracker_id => 3}
480 480 end
481 481
482 482 def test_post_new_without_custom_fields_param
@@ -533,6 +533,20 class IssuesControllerTest < ActionController::TestCase
533 533 assert [mail.bcc, mail.cc].flatten.include?(User.find(3).mail)
534 534 end
535 535
536 def test_post_new_subissue
537 @request.session[:user_id] = 2
538
539 assert_difference 'Issue.count' do
540 post :new, :project_id => 1,
541 :issue => {:tracker_id => 1,
542 :subject => 'This is a child issue',
543 :parent_issue_id => 2}
544 end
545 issue = Issue.find_by_subject('This is a child issue')
546 assert_not_nil issue
547 assert_equal Issue.find(2), issue.parent
548 end
549
536 550 def test_post_new_should_send_a_notification
537 551 ActionMailer::Base.deliveries.clear
538 552 @request.session[:user_id] = 2
@@ -1215,6 +1229,34 class IssuesControllerTest < ActionController::TestCase
1215 1229 :class => 'icon-del disabled' }
1216 1230 end
1217 1231
1232 def test_auto_complete_routing
1233 assert_routing(
1234 {:method => :get, :path => '/issues/auto_complete'},
1235 :controller => 'issues', :action => 'auto_complete'
1236 )
1237 end
1238
1239 def test_auto_complete_should_not_be_case_sensitive
1240 get :auto_complete, :project_id => 'ecookbook', :q => 'ReCiPe'
1241 assert_response :success
1242 assert_not_nil assigns(:issues)
1243 assert assigns(:issues).detect {|issue| issue.subject.match /recipe/}
1244 end
1245
1246 def test_auto_complete_should_return_issue_with_given_id
1247 get :auto_complete, :project_id => 'subproject1', :q => '13'
1248 assert_response :success
1249 assert_not_nil assigns(:issues)
1250 assert assigns(:issues).include?(Issue.find(13))
1251 end
1252
1253 def test_destroy_routing
1254 assert_recognizes( #TODO: use DELETE on issue URI (need to change forms)
1255 {:controller => 'issues', :action => 'destroy', :id => '1'},
1256 {:method => :post, :path => '/issues/1/destroy'}
1257 )
1258 end
1259
1218 1260 def test_destroy_issue_with_no_time_entries
1219 1261 assert_nil TimeEntry.find_by_issue_id(2)
1220 1262 @request.session[:user_id] = 2
@@ -329,7 +329,7 class IssueTest < ActiveSupport::TestCase
329 329
330 330 def test_move_to_another_project_with_same_category
331 331 issue = Issue.find(1)
332 assert issue.move_to(Project.find(2))
332 assert issue.move_to_project(Project.find(2))
333 333 issue.reload
334 334 assert_equal 2, issue.project_id
335 335 # Category changes
@@ -340,7 +340,7 class IssueTest < ActiveSupport::TestCase
340 340
341 341 def test_move_to_another_project_without_same_category
342 342 issue = Issue.find(2)
343 assert issue.move_to(Project.find(2))
343 assert issue.move_to_project(Project.find(2))
344 344 issue.reload
345 345 assert_equal 2, issue.project_id
346 346 # Category cleared
@@ -350,7 +350,7 class IssueTest < ActiveSupport::TestCase
350 350 def test_move_to_another_project_should_clear_fixed_version_when_not_shared
351 351 issue = Issue.find(1)
352 352 issue.update_attribute(:fixed_version_id, 1)
353 assert issue.move_to(Project.find(2))
353 assert issue.move_to_project(Project.find(2))
354 354 issue.reload
355 355 assert_equal 2, issue.project_id
356 356 # Cleared fixed_version
@@ -360,7 +360,7 class IssueTest < ActiveSupport::TestCase
360 360 def test_move_to_another_project_should_keep_fixed_version_when_shared_with_the_target_project
361 361 issue = Issue.find(1)
362 362 issue.update_attribute(:fixed_version_id, 4)
363 assert issue.move_to(Project.find(5))
363 assert issue.move_to_project(Project.find(5))
364 364 issue.reload
365 365 assert_equal 5, issue.project_id
366 366 # Keep fixed_version
@@ -370,7 +370,7 class IssueTest < ActiveSupport::TestCase
370 370 def test_move_to_another_project_should_clear_fixed_version_when_not_shared_with_the_target_project
371 371 issue = Issue.find(1)
372 372 issue.update_attribute(:fixed_version_id, 1)
373 assert issue.move_to(Project.find(5))
373 assert issue.move_to_project(Project.find(5))
374 374 issue.reload
375 375 assert_equal 5, issue.project_id
376 376 # Cleared fixed_version
@@ -380,7 +380,7 class IssueTest < ActiveSupport::TestCase
380 380 def test_move_to_another_project_should_keep_fixed_version_when_shared_systemwide
381 381 issue = Issue.find(1)
382 382 issue.update_attribute(:fixed_version_id, 7)
383 assert issue.move_to(Project.find(2))
383 assert issue.move_to_project(Project.find(2))
384 384 issue.reload
385 385 assert_equal 2, issue.project_id
386 386 # Keep fixed_version
@@ -392,7 +392,7 class IssueTest < ActiveSupport::TestCase
392 392 target = Project.find(2)
393 393 target.tracker_ids = [3]
394 394 target.save
395 assert_equal false, issue.move_to(target)
395 assert_equal false, issue.move_to_project(target)
396 396 issue.reload
397 397 assert_equal 1, issue.project_id
398 398 end
@@ -401,7 +401,7 class IssueTest < ActiveSupport::TestCase
401 401 issue = Issue.find(1)
402 402 copy = nil
403 403 assert_difference 'Issue.count' do
404 copy = issue.move_to(issue.project, nil, :copy => true)
404 copy = issue.move_to_project(issue.project, nil, :copy => true)
405 405 end
406 406 assert_kind_of Issue, copy
407 407 assert_equal issue.project, copy.project
@@ -412,8 +412,9 class IssueTest < ActiveSupport::TestCase
412 412 issue = Issue.find(1)
413 413 copy = nil
414 414 assert_difference 'Issue.count' do
415 copy = issue.move_to(Project.find(3), Tracker.find(2), :copy => true)
415 copy = issue.move_to_project(Project.find(3), Tracker.find(2), :copy => true)
416 416 end
417 copy.reload
417 418 assert_kind_of Issue, copy
418 419 assert_equal Project.find(3), copy.project
419 420 assert_equal Tracker.find(2), copy.tracker
@@ -421,7 +422,7 class IssueTest < ActiveSupport::TestCase
421 422 assert_nil copy.custom_value_for(2)
422 423 end
423 424
424 context "#move_to" do
425 context "#move_to_project" do
425 426 context "as a copy" do
426 427 setup do
427 428 @issue = Issue.find(1)
@@ -429,24 +430,24 class IssueTest < ActiveSupport::TestCase
429 430 end
430 431
431 432 should "allow assigned_to changes" do
432 @copy = @issue.move_to(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:assigned_to_id => 3}})
433 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:assigned_to_id => 3}})
433 434 assert_equal 3, @copy.assigned_to_id
434 435 end
435 436
436 437 should "allow status changes" do
437 @copy = @issue.move_to(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:status_id => 2}})
438 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:status_id => 2}})
438 439 assert_equal 2, @copy.status_id
439 440 end
440 441
441 442 should "allow start date changes" do
442 443 date = Date.today
443 @copy = @issue.move_to(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:start_date => date}})
444 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:start_date => date}})
444 445 assert_equal date, @copy.start_date
445 446 end
446 447
447 448 should "allow due date changes" do
448 449 date = Date.today
449 @copy = @issue.move_to(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:due_date => date}})
450 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:due_date => date}})
450 451
451 452 assert_equal date, @copy.due_date
452 453 end
@@ -457,7 +458,7 class IssueTest < ActiveSupport::TestCase
457 458 issue = Issue.find(12)
458 459 assert issue.recipients.include?(issue.author.mail)
459 460 # move the issue to a private project
460 copy = issue.move_to(Project.find(5), Tracker.find(2), :copy => true)
461 copy = issue.move_to_project(Project.find(5), Tracker.find(2), :copy => true)
461 462 # author is not a member of project anymore
462 463 assert !copy.recipients.include?(copy.author.mail)
463 464 end
@@ -77,6 +77,13 module Redmine
77 77 @custom_field_values = nil
78 78 end
79 79
80 def reset_custom_values!
81 @custom_field_values = nil
82 @custom_field_values_changed = true
83 values = custom_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
84 custom_values.each {|cv| cv.destroy unless custom_field_values.include?(cv)}
85 end
86
80 87 module ClassMethods
81 88 end
82 89 end
@@ -256,7 +256,7 module CollectiveIdea #:nodoc:
256 256 end
257 257
258 258 def leaf?
259 right - left == 1
259 new_record? || (right - left == 1)
260 260 end
261 261
262 262 # Returns true is this is a child node
General Comments 0
You need to be logged in to leave comments. Login now