From 6d9b97b3316883702937f38d1fd02ff1f34dfe08 Mon Sep 17 00:00:00 2001 From: Eric Davis Date: Wed, 2 Dec 2009 09:54:46 -0800 Subject: [PATCH] Ported the redmine_subtasks plugin from Aleksei Gusev. Issues can be made subissues of others. There is no technical limit on the depth though it could get confusing with deep hierarchies. Significant changes: * Issue list has a View Option which can organize the list into a hierarchy. * Issue details page will show a filtered list of the parent and subissues with full support for the right click context menu * When assigning subissues, a JavaScript search is used to find the target issue using the: issue id, subject, description, and notes. * Parent issues will total the estimates from child issue * Parent issues will use the last due date from it's child issues * Parent issues will calculate the % done based on it's child issues * Parent issues will inherit the latest Version from it's children Significant changes from the plugin: * Several performance optimizations including basic caching of calculated attributes and N+1 queries * Closing few private data leaks (security) #443 --- app/controllers/issues_controller.rb | 119 +++++++- app/controllers/projects_controller.rb | 2 + app/controllers/queries_controller.rb | 1 + app/helpers/issues_helper.rb | 22 ++ app/helpers/queries_helper.rb | 94 ++++++- app/helpers/versions_helper.rb | 28 ++ app/models/issue.rb | 233 +++++++++++++- app/models/query.rb | 30 ++ app/models/version.rb | 2 + app/models/view_option.rb | 20 ++ app/views/issues/_action_menu.rhtml | 8 + app/views/issues/_attributes.rhtml | 10 + app/views/issues/_list.rhtml | 44 ++- app/views/issues/_list_organized_by_parent.rhtml | 14 + app/views/issues/_parent_field.rhtml | 19 + app/views/issues/_subissues_list.rhtml | 17 + app/views/issues/context_menu.rhtml | 2 +- app/views/issues/index.rhtml | 6 + app/views/issues/show.rhtml | 6 + app/views/projects/roadmap.rhtml | 8 +- app/views/queries/_form.rhtml | 8 + app/views/settings/_issues.rhtml | 9 + app/views/versions/show.rhtml | 16 +- config/locales/en.yml | 14 + config/settings.yml | 12 + .../20090115162651_add_queries_view_options.rb | 9 + ...52_add_default_value_of_view_optoins_queries.rb | 11 + ...90406213813_add_issues_parent_id_lft_and_rgt.rb | 14 + db/migrate/20090406213899_issues_rebuild.rb | 15 + ...20091211204929_add_lft_rgt_indexes_to_issues.rb | 11 + ...091211205222_add_indexes_to_issues_parent_id.rb | 9 + lib/redmine.rb | 2 +- public/images/contract.png | Bin 0 -> 290 bytes public/images/corner-dots.gif | Bin 0 -> 59 bytes public/images/expand.png | Bin 0 -> 2939 bytes public/javascripts/application.js | 21 ++- public/stylesheets/application.css | 41 +++ test/fixtures/issues.yml | 169 ++++++++++ test/fixtures/queries.yml | 27 ++ test/fixtures/versions.yml | 19 + test/functional/issues_controller_test.rb | 346 +++++++++++++++++++- test/functional/projects_controller_test.rb | 6 + test/functional/queries_controller_test.rb | 17 + test/integration/projects_test.rb | 3 + test/unit/enumeration_test.rb | 4 +- test/unit/issue_priority_test.rb | 2 +- test/unit/issue_test.rb | 314 ++++++++++++++++++ test/unit/project_test.rb | 40 ++- test/unit/query_test.rb | 2 +- 49 files changed, 1760 insertions(+), 66 deletions(-) create mode 100644 app/models/view_option.rb create mode 100644 app/views/issues/_list_organized_by_parent.rhtml create mode 100644 app/views/issues/_parent_field.rhtml create mode 100644 app/views/issues/_subissues_list.rhtml create mode 100644 db/migrate/20090115162651_add_queries_view_options.rb create mode 100644 db/migrate/20090115162652_add_default_value_of_view_optoins_queries.rb create mode 100644 db/migrate/20090406213813_add_issues_parent_id_lft_and_rgt.rb create mode 100644 db/migrate/20090406213899_issues_rebuild.rb create mode 100644 db/migrate/20091211204929_add_lft_rgt_indexes_to_issues.rb create mode 100644 db/migrate/20091211205222_add_indexes_to_issues_parent_id.rb create mode 100644 public/images/contract.png create mode 100644 public/images/corner-dots.gif create mode 100644 public/images/expand.png diff --git a/app/controllers/issues_controller.rb b/app/controllers/issues_controller.rb index cd435eb..ac25337 100644 --- a/app/controllers/issues_controller.rb +++ b/app/controllers/issues_controller.rb @@ -21,9 +21,12 @@ class IssuesController < ApplicationController before_filter :find_issue, :only => [:show, :edit, :reply] before_filter :find_issues, :only => [:bulk_edit, :move, :destroy] - before_filter :find_project, :only => [:new, :update_form, :preview] - before_filter :authorize, :except => [:index, :changes, :gantt, :calendar, :preview, :context_menu] + before_filter :find_project, :only => [:new, :update_form, :preview, :add_subissue, :auto_complete_for_issue_parent] + before_filter :authorize, :except => [:index, :changes, :gantt, :calendar, :preview, :context_menu, :add_subissue] before_filter :find_optional_project, :only => [:index, :changes, :gantt, :calendar] + before_filter :find_parent_issue, :only => [:add_subissue] + before_filter :find_optional_parent_issue, :only => [:new] + accept_key_auth :index, :show, :changes rescue_from Query::StatementInvalid, :with => :query_statement_invalid @@ -45,6 +48,7 @@ class IssuesController < ApplicationController include IssuesHelper helper :timelog include Redmine::Export::PDF + include ActionView::Helpers::PrototypeHelper verify :method => :post, :only => :destroy, @@ -102,6 +106,7 @@ class IssuesController < ApplicationController end def show + retrieve_query_for_subissues @journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC") @journals.each_with_index {|j,i| j.indice = i+1} @journals.reverse! if User.current.wants_comments_in_reverse_order? @@ -151,6 +156,7 @@ class IssuesController < ApplicationController # Check that the user is allowed to apply the requested status @issue.status = (@allowed_statuses.include? requested_status) ? requested_status : default_status call_hook(:controller_issues_new_before_save, { :params => params, :issue => @issue }) + @issue.parent_id = params[:issue][:parent_id] if params[:issue] if @issue.save attach_files(@issue, params[:attachments]) flash[:notice] = l(:notice_successful_create) @@ -185,6 +191,8 @@ class IssuesController < ApplicationController end if request.post? + @issue.parent_id = params[:issue][:parent_id] if params[:issue] + @time_entry = TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today) @time_entry.attributes = params[:time_entry] attachments = attach_files(@issue, params[:attachments]) @@ -212,6 +220,12 @@ class IssuesController < ApplicationController attachments.each(&:destroy) end + def add_subissue + redirect_to :action => 'new', + :project_id => @parent_issue.project, + :issue => { :parent_id => @parent_issue.id } + end + def reply journal = Journal.find(params[:journal_id]) if params[:journal_id] if journal @@ -369,10 +383,25 @@ class IssuesController < ApplicationController :order => "start_date, effective_date", :conditions => ["(((start_date>=? and start_date<=?) or (effective_date>=? and effective_date<=?) or (start_date?)) and start_date is not null and due_date is null and effective_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to] ) + # Parent issues that might not have the due_date set but do have + # child issues that do have due_date set should be included. + events.each do |issue| + if issue.leaf? + # Can't use the Issue#visible named_scope because it causes + # a SQL error with the awesome_nested_set + ancestors = issue.ancestors.all(:include => [:tracker, :assigned_to, :priority, :project], + :order => "start_date", + :conditions => 'start_date IS NOT NULL') + ancestors.map! {|i| i.visible? ? i : nil }.compact! + + events += ancestors.flatten if ancestors.present? + end + end + # Versions events += @query.versions(:conditions => ["effective_date BETWEEN ? AND ?", @gantt.date_from, @gantt.date_to]) - - @gantt.events = events + + @gantt.events = events.uniq end basename = (@project ? "#{@project.identifier}-" : '') + 'gantt' @@ -458,14 +487,72 @@ class IssuesController < ApplicationController @text = params[:notes] || (params[:issue] ? params[:issue][:description] : nil) render :partial => 'common/preview' end + + def auto_complete_for_issue_parent + @phrase = params[:issue_parent] + @candidates = [] + + # If cross project issue relations is allowed we should get + # candidates from every project + if Setting.cross_project_issue_relations? + projects_to_search = nil + else + projects_to_search = [ @project ] + @project.children + end + + if @phrase.present? + # Try to find issue by id. + if @phrase.match(/^#?(\d+)$/) + if Setting.cross_project_issue_relations? + issue = Issue.visible.find_by_id( $1) + else + issue = Issue.visible.find_by_id_and_project_id( $1, projects_to_search.collect { |i| i.id}) + end + @candidates << issue if issue + end + + # Search by subject and description + # extract tokens from the question + # eg. hello "bye bye" => ["hello", "bye bye"] + tokens = @phrase.scan(%r{((\s|^)"[\s\w]+"(\s|$)|\S+)}).collect {|m| m.first.gsub(%r{(^\s*"\s*|\s*"\s*$)}, '')} + # tokens must be at least 3 character long + tokens = tokens.uniq.select {|w| w.length > 2 } + like_tokens = tokens.collect {|w| "%#{w.downcase}%"} + + search_results, count = Issue.search( like_tokens, projects_to_search, :before => true, :limit => 10) + @candidates += search_results unless search_results.empty? + end + + # Remove the current issue if it's a result + if params[:id].present? + @issue = Issue.visible.find_by_id(params[:id]) + @candidates.delete(@issue) + end + + render :inline => "<%= auto_complete_result_parent_issue( @candidates, @phrase) %>" + end private def find_issue @issue = Issue.find(params[:id], :include => [:project, :tracker, :status, :author, :priority, :category]) @project = @issue.project + @parent_issue = @issue.parent if @issue + rescue ActiveRecord::RecordNotFound + render_404 + end + + def find_parent_issue + @parent_issue = Issue.find( params[:parent_issue_id]) + render_404 unless @parent_issue.visible?(User.current) rescue ActiveRecord::RecordNotFound render_404 end + + def find_optional_parent_issue + if params[:issue] && !params[:issue][:parent_id].blank? + @parent_issue = Issue.visible.find_by_id( params[:issue][:parent_id]) + end + end # Filter for bulk operations def find_issues @@ -522,14 +609,36 @@ private end @query.group_by = params[:group_by] @query.column_names = params[:query] && params[:query][:column_names] - session[:query] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names} + if params[:view_options] and params[:view_options].is_a? Hash + params[:view_options].each_pair do |name, value| + @query.set_view_option( name, value) + end + end + + session[:query] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names, :view_options => @query.view_options} else @query = Query.find_by_id(session[:query][:id]) if session[:query][:id] @query ||= Query.new(:name => "_", :project => @project, :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names]) + if session[:query][:view_options] + session[:query][:view_options].each_pair do |name, value| + @query.set_view_option( name, value) + end + end @query.project = @project end end end + + # Retrive and build a query for the subissues + def retrieve_query_for_subissues + retrieve_query + @query.project = @project + @query.set_view_option('show_parents', ViewOption::SHOW_PARENTS[:organize_by]) + @query.column_names = Setting.subissues_list_columns + sort_init( @query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria) + sort_update({'id' => "#{Issue.table_name}.id"}.merge( @query.available_columns.inject({}) { |h, c| h[c.name.to_s] = c.sortable; h})) + + end # Rescues an invalid query statement. Just in case... def query_statement_invalid(exception) diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index e908388..98a54ed 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -46,6 +46,8 @@ class ProjectsController < ApplicationController helper :repositories include RepositoriesHelper include ProjectsHelper + helper :versions + include VersionsHelper # Lists visible projects def index diff --git a/app/controllers/queries_controller.rb b/app/controllers/queries_controller.rb index 16755a1..93b33c2 100644 --- a/app/controllers/queries_controller.rb +++ b/app/controllers/queries_controller.rb @@ -31,6 +31,7 @@ class QueriesController < ApplicationController @query.add_filter(field, params[:operators][field], params[:values][field]) end if params[:fields] @query.group_by ||= params[:group_by] + @query.view_options = params[:view_options] if params[:view_options] if request.post? && params[:confirm] && @query.save flash[:notice] = l(:notice_successful_create) diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 1f74011..8aec012 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -96,6 +96,15 @@ module IssuesHelper when 'estimated_hours' value = "%0.02f" % detail.value.to_f unless detail.value.blank? old_value = "%0.02f" % detail.old_value.to_f unless detail.old_value.blank? + when 'parent_id' + if detail.value && i = Issue.visible.find_by_id(detail.value) + value = i.subject + end + + if detail.old_value && i = Issue.visible.find_by_id(detail.old_value) + old_value = i.subject + end + label = l(:field_parent_issue) end when 'cf' custom_field = CustomField.find_by_id(detail.prop_key) @@ -196,4 +205,17 @@ module IssuesHelper end export end + + def auto_complete_result_parent_issue(candidates, phrase) + if candidates.present? + candidates.map! do |c| + content_tag("li", + highlight( c.to_s, phrase), + :id => String( c[:id])) + end + else + candidates = [content_tag(:li, l(:label_none), :style => 'display:none')] + end + content_tag("ul", candidates.uniq) + end end diff --git a/app/helpers/queries_helper.rb b/app/helpers/queries_helper.rb index ecfac55..66ac7ba 100644 --- a/app/helpers/queries_helper.rb +++ b/app/helpers/queries_helper.rb @@ -27,13 +27,13 @@ module QueriesHelper content_tag('th', column.caption) end - def column_content(column, issue) + def column_content(column, issue, query = nil) value = column.value(issue) case value.class.name when 'String' if column.name == :subject - link_to(h(value), :controller => 'issues', :action => 'show', :id => issue) + subject_in_tree( issue, issue.subject, query) else h(value) end @@ -61,4 +61,94 @@ module QueriesHelper h(value) end end + + def subject_in_tree(issue, value, query) + if query.view_options['show_parents'] == ViewOption::SHOW_PARENTS[:never] + content_tag('div', subject_text(issue, value), :class=>'issue-subject') + else + css_style = "margin-left: #{issue.level}em;" # Used to indent + content_tag('span', + content_tag('div', + subject_text(issue, value), + :class=>'issue-subject', + :style => css_style), + :class => issue.level > 0 ? "issue-subject-in-tree issue-level-#{issue.level}" : '', + :style => css_style) + end + end + + def subject_text(issue, value) + if issue.visible? + subject_text = link_to(h(value), :controller => 'issues', :action => 'show', :id => issue) + h((@project.nil? || @project != issue.project) ? "#{issue.project.name} - " : '') + subject_text + else + h(value) + end + end + + def issue_content(issue, query, options = { }) + row_classes = ['issue','hascontextmenu', issue.css_classes, cycle('odd', 'even')] + row_classes << 'issue-unfiltered' if options[:unfiltered] + row_classes << 'issue-emphasis' if options[:emphasis] + + inner_content = returning '' do |content| + content << content_tag(:td, check_box_tag("ids[]", issue.id, false, :id => nil), :class => 'checkbox') + content << content_tag(:td, link_to(issue.id, :controller => 'issues', :action => 'show', :id => issue)) + + query.columns.each do |column| + content << content_tag( 'td', column_content(column, issue, query), :class => column.name) + end + end + + content_tag(:tr, + inner_content, + :id => "issue-#{issue.id}", + :class => row_classes.join(' ')) + end + + def private_issue_content(issue, query, options = { }) + row_classes = ['issue', 'private-issue',cycle('odd', 'even')] + row_classes << 'issue-unfiltered' if options[:unfiltered] + row_classes << 'issue-emphasis' if options[:emphasis] + + inner_content = returning '' do |content| + content << content_tag(:td, check_box_tag("ids[]", '', false, :id => nil), :class => 'checkbox') + content << content_tag(:td, l(:text_private)) + + query.columns.each do |column| + if column.name == :subject + # Need to indent + content << content_tag('td', subject_in_tree(issue, l(:text_private), query), :class => column.name) + else + content << content_tag( 'td', l(:text_private), :class => column.name) + end + end + end + + content_tag(:tr, + inner_content, + :id => "", + :class => row_classes.join(' ')) + + end + + def issues_family_content( parent, issues_to_show, query, emphasis_issues) + html = "" + if parent.visible? + html << issue_content( parent, query, :unfiltered => !( issues_to_show.include? parent), + :emphasis => ( emphasis_issues ? emphasis_issues.include?( parent) : false)) + else + html << private_issue_content( parent, query, :unfiltered => !( issues_to_show.include? parent), + :emphasis => ( emphasis_issues ? emphasis_issues.include?( parent) : false)) + end + unless parent.children.empty? + parent.children.each do |child| + if issues_to_show.include?( child) || issues_to_show.detect { |i| i.ancestors.include? child } + html << issues_family_content( child, issues_to_show, query, emphasis_issues) + end + end + end + html + end + end diff --git a/app/helpers/versions_helper.rb b/app/helpers/versions_helper.rb index 0fcc640..1b15c1a 100644 --- a/app/helpers/versions_helper.rb +++ b/app/helpers/versions_helper.rb @@ -44,4 +44,32 @@ module VersionsHelper def status_by_options_for_select(value) options_for_select(STATUS_BY_CRITERIAS.collect {|criteria| [l("field_#{criteria}".to_sym), criteria]}, value) end + + def render_list_of_related_issues( issues, version, current_level = 0) + issues_on_current_level = issues.select { |i| i.level == current_level } + issues -= issues_on_current_level + content_tag( 'ul') do + html = '' + issues_on_current_level.each do |issue| + opts_for_issue_li = { } + if !issue.fixed_version or issue.fixed_version != version + opts_for_issue_li[:class] = 'issue-unfiltered' + end + html << content_tag( 'li', opts_for_issue_li) do + opts = { } + if issue.done_ratio == 100 + opts[:style] = 'font-weight: bold' + end + link_to_issue(issue, opts) + end + children_to_print = issues & issue.children + children_to_print += issues.select { |i| i.level >= current_level + 2} + unless children_to_print.empty? + html << render_list_of_related_issues( children_to_print, version, current_level + 1) + end + end + html + end + end + end diff --git a/app/models/issue.rb b/app/models/issue.rb index b9e0461..9ba7be5 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -36,7 +36,7 @@ class Issue < ActiveRecord::Base acts_as_customizable acts_as_watchable acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"], - :include => [:project, :journals], + :include => [:project, :journals, :tracker], # sort by id so that limited eager loading doesn't break with postgresql :order_column => "#{table_name}.id" acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"}, @@ -46,12 +46,37 @@ class Issue < ActiveRecord::Base acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]}, :author_key => :author_id - DONE_RATIO_OPTIONS = %w(issue_field issue_status) + # Needs to be registered before any before_destroy in acts_as_nested_set + before_destroy :move_children_to_root_before_destroy + + acts_as_nested_set + + # Patches to acts_as_nested_set since Issue already defines #move_to + def move_to_left_of(node) + nested_set_move_to node, :left + end + + def move_to_right_of(node) + nested_set_move_to node, :right + end + + def move_to_child_of(node) + nested_set_move_to node, :child + end + + def move_to_root + nested_set_move_to nil, :root + end + + alias_method :nested_set_move_to, :move_to + DONE_RATIO_OPTIONS = %w(issue_field issue_status) + validates_presence_of :subject, :priority, :project, :tracker, :author, :status validates_length_of :subject, :maximum => 255 validates_inclusion_of :done_ratio, :in => 0..100 validates_numericality_of :estimated_hours, :allow_nil => true + validate :subtasks_validation named_scope :visible, lambda {|*args| { :include => :project, :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } } @@ -60,6 +85,8 @@ class Issue < ActiveRecord::Base before_save :update_done_ratio_from_issue_status after_save :create_journal + after_save :set_parent + after_save :do_subtasks_hooks # Returns true if usr or current user is allowed to view the issue def visible?(usr=nil) @@ -91,13 +118,20 @@ class Issue < ActiveRecord::Base # Returns the moved/copied issue on success, false on failure def move_to(new_project, new_tracker = nil, options = {}) options ||= {} - issue = options[:copy] ? self.clone : self + issue = if options[:copy] + Issue.new( self.attributes.reject { |k,v| k == 'parent_id' }) + else + self + end + transaction do if new_project && issue.project_id != new_project.id # delete issue relations unless Setting.cross_project_issue_relations? issue.relations_from.clear issue.relations_to.clear + + issue.children.each(&:move_to_root) unless options[:copy] end # issue is moved to another project # reassign to the category with same name if any @@ -129,6 +163,9 @@ class Issue < ActiveRecord::Base # Manually update project_id on related time entries TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id}) end + if new_project && issue.project_id != new_project.id && !Setting.cross_project_issue_relations? + issue.move_to_root + end else Issue.connection.rollback_db_transaction return false @@ -136,7 +173,16 @@ class Issue < ActiveRecord::Base end return issue end - + + # Cache awesome_nested_set's level attribute, it goes back to the + # database and counts ancestors which can be expensive. + def level + unless @level + @level = super + end + @level + end + def priority_id=(pid) self.priority = nil write_attribute(:priority_id, pid) @@ -160,16 +206,81 @@ class Issue < ActiveRecord::Base self.attributes_without_tracker_first = new_attributes, *args end alias_method_chain :attributes=, :tracker_first + + # Need to define the setter because awesome_nested_set removes the + # parent_id setter since parent is an internal field. If parent + # isn't set though, then parent changes will not be logged to journals. + def parent_id=(pid) + if pid != id + write_attribute(:parent_id, pid) + else + false # Circular reference + end + end + + def estimated_hours + if leaf? + read_attribute(:estimated_hours) + else + children.inject(0) do |sum, issue| + if issue.estimated_hours.present? + sum + issue.estimated_hours + else + sum + end + end + end + end + + # Returns the estimated_hours, disregarding child issues + def original_estimated_hours + read_attribute(:estimated_hours) + end def estimated_hours=(h) - write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h) + write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h) if leaf? + end + + def due_date + if leaf? + read_attribute( :due_date) + else + unless @due_date # cache, expensive operation + dates = leaves.map(&:due_date) + @due_date = dates.select {|d| d }.max if (dates && dates.any?) + end + @due_date + end + end + + [ :due_date, :done_ratio ].each do |method| + src = <<-END_SRC + def #{method}=(value) + write_attribute( :#{method}, value) if leaf? + end + END_SRC + class_eval src, __FILE__, __LINE__ end def done_ratio - if Issue.use_status_for_done_ratio? && status && status.default_done_ratio? - status.default_done_ratio + if leaf? + if Issue.use_status_for_done_ratio? && status && status.default_done_ratio? + status.default_done_ratio + else + read_attribute(:done_ratio) + end else - read_attribute(:done_ratio) + unless @done_ratio # cache, expensive operation + total_planned_days = leaves.inject(0) {|sum,i| sum + i.duration} + + if total_planned_days == 0 + @done_ratio = 0 + else + total_actual_days = leaves.inject(0) {|sum,i| sum + i.actual_days} + @done_ratio = (total_actual_days * 100 / total_planned_days).floor + end + end + @done_ratio end end @@ -182,7 +293,7 @@ class Issue < ActiveRecord::Base end def validate - if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty? + if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty? && leaf? errors.add :due_date, :not_a_date end @@ -231,7 +342,13 @@ class Issue < ActiveRecord::Base # Update start/due dates of following issues relations_from.each(&:set_issue_to_dates) - + + # If target version is set, but "Due to" date is not, set + # it as the same as the date of target version. + if leaf? && due_date.nil? && fixed_version && fixed_version.due_date + self.update_attribute :due_date, fixed_version.due_date + end + # Close duplicates if the issue was closed if @issue_before_change && !@issue_before_change.closed? && self.closed? duplicates.each do |duplicate| @@ -256,11 +373,19 @@ class Issue < ActiveRecord::Base updated_on_will_change! @current_journal end + + def journal_initilized? + @current_journal + end # Return true if the issue is closed, otherwise false def closed? self.status.is_closed? end + + def open? + !closed? + end # Return true if the issue is being reopened def reopened? @@ -359,6 +484,17 @@ class Issue < ActiveRecord::Base def soonest_start @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min end + + # Returns the number of days that have been worked on this issue. + # Calculated by using the duration of the issue (start/end dates) + # and the done ratio + def actual_days + if done_ratio + (duration * done_ratio / 100).floor + else + 0 + end + end def to_s "#{tracker} ##{id}: #{subject}" @@ -388,6 +524,10 @@ class Issue < ActiveRecord::Base Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids]) end + def leaf? + new_record? || (right - left == 1) + end + private # Update issues so their versions are not pointing to a @@ -402,7 +542,7 @@ class Issue < ActiveRecord::Base :include => [:project, :fixed_version] ).each do |issue| next if issue.project.nil? || issue.fixed_version.nil? - unless issue.project.shared_versions.include?(issue.fixed_version) + unless issue.project.shared_versions.collect(&:id).include?(issue.fixed_version_id) issue.init_journal(User.current) issue.fixed_version = nil issue.save @@ -424,7 +564,11 @@ class Issue < ActiveRecord::Base def create_journal if @current_journal # attributes changes - (Issue.column_names - %w(id description lock_version created_on updated_on)).each {|c| + skip_attrs = %w(id description lock_version created_on updated_on) + skip_attrs += %w(due_date done_ratio estimated_hours) unless leaf? + + # attributes changes + (Issue.column_names - skip_attrs).each {|c| @current_journal.details << JournalDetail.new(:property => 'attr', :prop_key => c, :old_value => @issue_before_change.send(c), @@ -442,4 +586,69 @@ class Issue < ActiveRecord::Base @current_journal.save end end + + + def move_children_to_root_before_destroy + unless Setting.delete_children? + children.each( &:move_to_root) + reload_nested_set + end + end + + def do_subtasks_hooks + if parent + # Need to reload the Issues. Using the association or + # parent.reload was keeping the object readonly. + parent_issue = Issue.find parent.id + self.reload + + # Update the parent status if this issue is open and the parent + # is closed + if open? && parent_issue.closed? + parent_issue.init_journal(User.current) + parent_issue.status = IssueStatus.find_by_id(Setting.reopened_parent_issue_status) || IssueStatus.default + end + + # Set 'Target version' of parent if one was set on one of the + # children issue and parent have no 'Target version'. Do the same + # if 'Target version of the parent issue lower (by the release + # date or by the version number). + if parent_issue.fixed_version.nil? && fixed_version or + ( parent_issue.fixed_version && fixed_version and + parent_issue.fixed_version.project == fixed_version.project and + parent_issue.fixed_version < fixed_version ) + parent_issue.init_journal(User.current) unless parent_issue.journal_initilized? + parent_issue.fixed_version = fixed_version + end + parent_issue.save if parent_issue.changed? + end + end + + def set_parent + if (@issue_before_change && @issue_before_change.parent_id != parent_id) || + self.lock_version == 0 # Newly saved record + if parent_id.present? + parent_issue = Issue.visible.find_by_id(parent_id) + move_to_child_of parent_issue if parent_issue + else + move_to_root + end + end + end + + def subtasks_validation + unless children.empty? + if IssueStatus.find_by_id( @attributes['status_id']).is_closed? && children.detect { |i| !i.closed? } + errors.add( :status, l(:error_issue_subtasks_cant_close_parent)) + end + + children_max_fixed_version = children.select { |i| i.fixed_version } .max { |a,b| a.fixed_version <=> b.fixed_version } + if @attributes['fixed_version_id'] && children_max_fixed_version + if Version.find_by_id( @attributes['fixed_version_id']) < children_max_fixed_version.fixed_version + errors.add :fixed_version, l(:error_issue_subtasks_cant_select_lower_target_version) + end + end + end + end + end diff --git a/app/models/query.rb b/app/models/query.rb index 788f34e..89b0df5 100644 --- a/app/models/query.rb +++ b/app/models/query.rb @@ -78,6 +78,7 @@ class Query < ActiveRecord::Base serialize :filters serialize :column_names serialize :sort_criteria, Array + serialize :view_options attr_protected :project_id, :user_id @@ -135,10 +136,23 @@ class Query < ActiveRecord::Base QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'), ] cattr_reader :available_columns + + @@available_view_options = + [ ViewOption.new( 'show_parents', + [ [ l(:label_view_option_parents_do_not_show), + ViewOption::SHOW_PARENTS[:never] ], + [ l(:label_view_option_parents_show_always), + ViewOption::SHOW_PARENTS[:always] ], + [ l(:label_view_option_parents_show_and_group), + ViewOption::SHOW_PARENTS[:organize_by]]]) + ] + cattr_reader :available_view_options + def initialize(attributes = nil) super attributes self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} } + self.view_options ||= { 'show_parents' => 'do_not_show' } end def after_initialize @@ -470,6 +484,22 @@ class Query < ActiveRecord::Base rescue ::ActiveRecord::StatementInvalid => e raise StatementInvalid.new(e.message) end + + def set_view_option( option, value) + self.view_options[option] = value + # Clear group_by if organize_by_parent is selected + if option == 'show_parents' && value == 'organize_by_parent' + self.group_by = nil + end + end + + def values_for_view_option( option) + @@available_view_options.find { |vo| vo.name == option }.available_values + end + + def caption_for_view_option( option) + @@available_view_options.find { |vo| vo.name == option }.caption + end private diff --git a/app/models/version.rb b/app/models/version.rb index bc0e17e..6436248 100644 --- a/app/models/version.rb +++ b/app/models/version.rb @@ -16,6 +16,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. class Version < ActiveRecord::Base + include Comparable + before_destroy :check_integrity after_update :update_issues_from_sharing_change belongs_to :project diff --git a/app/models/view_option.rb b/app/models/view_option.rb new file mode 100644 index 0000000..efde729 --- /dev/null +++ b/app/models/view_option.rb @@ -0,0 +1,20 @@ +class ViewOption + attr_accessor :name, :available_values + include Redmine::I18n + + unless const_defined? :SHOW_PARENTS + SHOW_PARENTS = { :never => 'do_not_show', + :always => 'show_always', + :organize_by => 'organize_by_parent'}.freeze + end + + def initialize( name, available_values) + self.name = name + self.available_values = available_values + end + + def caption + l("label_view_option_#{name}") + end +end + diff --git a/app/views/issues/_action_menu.rhtml b/app/views/issues/_action_menu.rhtml index 693b492..96df1ca 100644 --- a/app/views/issues/_action_menu.rhtml +++ b/app/views/issues/_action_menu.rhtml @@ -1,5 +1,13 @@
<%= link_to_if_authorized(l(:button_update), {:controller => 'issues', :action => 'edit', :id => @issue }, :onclick => 'showAndScrollTo("update", "notes"); return false;', :class => 'icon icon-edit', :accesskey => accesskey(:edit)) %> +<%= link_to_if_authorized(l(:button_add_subissue), + { + :controller => 'issues', + :action => 'add_subissue', + :project_id => @project, + :parent_issue_id => @issue.id + }, + :class => 'icon icon-add') %> <%= link_to_if_authorized l(:button_log_time), {:controller => 'timelog', :action => 'edit', :issue_id => @issue}, :class => 'icon icon-time-add' %> <% replace_watcher ||= 'watcher' %> <%= watcher_tag(@issue, User.current, {:id => replace_watcher, :replace => ['watcher','watcher2']}) %> diff --git a/app/views/issues/_attributes.rhtml b/app/views/issues/_attributes.rhtml index f8fc8d6..21928fe 100644 --- a/app/views/issues/_attributes.rhtml +++ b/app/views/issues/_attributes.rhtml @@ -31,12 +31,22 @@
+<% if @issue.new_record? || @issue.leaf? %>

<%= f.text_field :start_date, :size => 10 %><%= calendar_for('issue_start_date') %>

<%= f.text_field :due_date, :size => 10 %><%= calendar_for('issue_due_date') %>

<%= f.text_field :estimated_hours, :size => 3 %> <%= l(:field_hours) %>

<% if Issue.use_field_for_done_ratio? %>

<%= f.select :done_ratio, ((0..10).to_a.collect {|r| ["#{r*10} %", r*10] }) %>

<% end %> +<% else %> +

<%= format_date(@issue.start_date) %>

+

<%= format_date(@issue.due_date) %>

+

<%= "#{@issue.done_ratio}%" %>

+<% end %> +
+ +
+<%= render :partial => 'parent_field' %>
diff --git a/app/views/issues/_list.rhtml b/app/views/issues/_list.rhtml index a7a7b06..0cee648 100644 --- a/app/views/issues/_list.rhtml +++ b/app/views/issues/_list.rhtml @@ -12,23 +12,33 @@ <% previous_group = false %> - <% issues.each do |issue| -%> - <% if @query.grouped? && (group = @query.group_by_column.value(issue)) != previous_group %> - <% reset_cycle %> - - -   - <%= group.blank? ? 'None' : column_content(@query.group_by_column, issue) %> (<%= @issue_count_by_group[group] %>) - - - <% previous_group = group %> - <% end %> - - <%= check_box_tag("ids[]", issue.id, false, :id => nil) %> - <%= link_to issue.id, :controller => 'issues', :action => 'show', :id => issue %> - <% query.columns.each do |column| %><%= content_tag 'td', column_content(column, issue), :class => column.name %><% end %> - - <% end -%> + <% emphasis_issues ||= [] %> + <% if query.view_options['show_parents'] == ViewOption::SHOW_PARENTS[:organize_by] -%> + <%= render :partial => 'list_organized_by_parent', :locals => { :issues => issues, :query => query, :emphasis_issues => emphasis_issues }%> + <% else %> + <% issues.each do |issue| -%> + <% if @query.grouped? && (group = @query.group_by_column.value(issue)) != previous_group %> + <% reset_cycle %> + + +   + <%= group.blank? ? 'None' : column_content(@query.group_by_column, issue) %> (<%= @issue_count_by_group[group] %>) + + + <% previous_group = group %> + <% end %> + <% if query.view_options['show_parents'] == ViewOption::SHOW_PARENTS[:always] -%> + <% issue.ancestors.each do |parent_issue| -%> + <% if parent_issue.visible? %> + <%= issue_content( parent_issue, query, :unfiltered => true) %> + <% else %> + <%= private_issue_content( parent_issue, query, :unfiltered => true) %> + <% end %> + <% end -%> + <% end %> + <%= issue_content( issue, query, :emphasis => ( emphasis_issues ? emphasis_issues.include?( issue) : false)) %> + <% end -%> + <% end -%> <% end -%> diff --git a/app/views/issues/_list_organized_by_parent.rhtml b/app/views/issues/_list_organized_by_parent.rhtml new file mode 100644 index 0000000..9c95833 --- /dev/null +++ b/app/views/issues/_list_organized_by_parent.rhtml @@ -0,0 +1,14 @@ +<%- +parents_on_first_lvl = [] +issues.each do |i| + if i.parent + first_parent = i.root + else + first_parent = i + end + parents_on_first_lvl += [ first_parent ] unless parents_on_first_lvl.include?( first_parent) +end + +parents_on_first_lvl.each do |parent| -%> +<%= issues_family_content( parent, issues, query, emphasis_issues) %> +<% end -%> diff --git a/app/views/issues/_parent_field.rhtml b/app/views/issues/_parent_field.rhtml new file mode 100644 index 0000000..e36b59d --- /dev/null +++ b/app/views/issues/_parent_field.rhtml @@ -0,0 +1,19 @@ +<%= hidden_field_tag('issue[parent_id]', (@parent_issue ? @parent_issue.id : ""), :id => :issue_parent_id) %> +

+<% if authorize_for( 'issues', 'auto_complete_for_issue_parent') %> + <% if @parent_issue && @parent_issue.visible? %> + <%= text_field_tag( 'parent_issue', '', :value => @parent_issue) %> + <% else %> + <%= text_field_tag( 'parent_issue', '', :value => @parent_issue ? l(:text_private) : '') %> + <% end %> + <%= link_to_function( "Remove", 'clearValues(["issue_parent_id", "parent_issue"])') %> + +

+ <%= javascript_tag "observeParentIssueField('#{url_for(:controller => :issues, + :action => :auto_complete_for_issue_parent, + :id => @issue.id, + :project_id => @project.id) }')" %> +<% else %> + <%= @parent_issue || "-" %> +<% end %> +

diff --git a/app/views/issues/_subissues_list.rhtml b/app/views/issues/_subissues_list.rhtml new file mode 100644 index 0000000..b2c438e --- /dev/null +++ b/app/views/issues/_subissues_list.rhtml @@ -0,0 +1,17 @@ +<% if @issue.root.self_and_descendants.size > 1 %> + <% content_for :header_tags do %> + <%= javascript_include_tag 'context_menu' %> + <%= stylesheet_link_tag 'context_menu' %> + <% end %> +
+

<%=l(:label_issues_hierarchy)%>

+
+ <%= render( :partial => 'issues/list', + :locals => { + :issues => @issue.root.self_and_descendants, + :emphasis_issues => [ @issue ], + :query => @query }) %> +
+ + <%= javascript_tag "new ContextMenu('#{url_for(:controller => 'issues', :action => 'context_menu')}')" %> +<% end %> diff --git a/app/views/issues/context_menu.rhtml b/app/views/issues/context_menu.rhtml index 4a1d0c3..3873dca 100644 --- a/app/views/issues/context_menu.rhtml +++ b/app/views/issues/context_menu.rhtml @@ -83,7 +83,7 @@ diff --git a/app/views/issues/index.rhtml b/app/views/issues/index.rhtml index 5b8fa05..dfb8328 100644 --- a/app/views/issues/index.rhtml +++ b/app/views/issues/index.rhtml @@ -29,6 +29,12 @@ <%= l(:field_group_by) %> <%= select_tag('group_by', options_for_select([[]] + @query.groupable_columns.collect {|c| [c.caption, c.name.to_s]}, @query.group_by)) %> + <% @query.view_options.each_key do |voption| -%> + + <%= @query.caption_for_view_option(voption) %> + <%= select_tag( "view_options[#{voption}]", options_for_select(@query.values_for_view_option(voption), @query.view_options[voption])) %> + + <% end %> diff --git a/app/views/issues/show.rhtml b/app/views/issues/show.rhtml index 90a9d96..939a6b6 100644 --- a/app/views/issues/show.rhtml +++ b/app/views/issues/show.rhtml @@ -38,6 +38,10 @@ <%=l(:field_estimated_hours)%>:<%= l_hours(@issue.estimated_hours) %> <% end %> +<% if !@issue.leaf? && @issue.original_estimated_hours %> +   + <%=l(:field_original_estimated_hours)%>:<%= l_hours(@issue.original_estimated_hours) %> +<% end %> <%= render_custom_fields_rows(@issue) %> <%= call_hook(:view_issues_show_details_bottom, :issue => @issue) %> @@ -54,6 +58,8 @@ <%= link_to_attachments @issue %> +<%= render :partial => 'subissues_list' %> + <%= call_hook(:view_issues_show_description_bottom, :issue => @issue) %> <% if authorize_for('issue_relations', 'new') || @issue.relations.any? %> diff --git a/app/views/projects/roadmap.rhtml b/app/views/projects/roadmap.rhtml index bcc3684..6e8821f 100644 --- a/app/views/projects/roadmap.rhtml +++ b/app/views/projects/roadmap.rhtml @@ -10,11 +10,13 @@ <%= render(:partial => "wiki/content", :locals => {:content => version.wiki_page.content}) if version.wiki_page %> <% if (issues = @issues_by_version[version]) && issues.size > 0 %> + <% issues.each do |i| + issues += i.ancestors if i.child? + end + issues.uniq! %> <% end %> diff --git a/app/views/queries/_form.rhtml b/app/views/queries/_form.rhtml index dcafe9f..c70fb5f 100644 --- a/app/views/queries/_form.rhtml +++ b/app/views/queries/_form.rhtml @@ -22,6 +22,14 @@

<%= select 'query', 'group_by', @query.groupable_columns.collect {|c| [c.caption, c.name.to_s]}, :include_blank => true %>

+ +<% @query.view_options.each_key do |voption| -%> +

+<%= select_tag("view_options[#{voption}]", options_for_select(@query.values_for_view_option(voption), @query.view_options[voption])) %>

+<% end %> + +

+<%= select 'query', 'group_by', @query.groupable_columns.collect {|c| [c.caption, c.name.to_s]}, :include_blank => true %>

<%= l(:label_filter_plural) %> diff --git a/app/views/settings/_issues.rhtml b/app/views/settings/_issues.rhtml index 4280e44..eb86b87 100644 --- a/app/views/settings/_issues.rhtml +++ b/app/views/settings/_issues.rhtml @@ -8,6 +8,10 @@

<%= setting_select :issue_done_ratio, Issue::DONE_RATIO_OPTIONS.collect {|i| [l("setting_issue_done_ratio_#{i}"), i]} %>

<%= setting_text_field :issues_export_limit, :size => 6 %>

+ +

<%= setting_select :delete_children, [ [l(:general_text_Yes), "1" ], [l(:general_text_No), "0" ] ] %>

+ +

<%= setting_select :reopened_parent_issue_status, [["", 0]] + IssueStatus.all(:order => 'position ASC').collect{|status| [status.name, status.id.to_s]} %>

<%= l(:setting_issue_list_default_columns) %> @@ -15,5 +19,10 @@ Query.new.available_columns.collect {|c| [c.caption, c.name.to_s]}, :label => false) %>
+ +
<%= l(:setting_subissues_list_columns) %> +<%= setting_multiselect(:subissues_list_columns, Query.new.available_columns.collect {|c| [c.caption, c.name.to_s]}, :label => false) %> +
+ <%= submit_tag l(:button_save) %> <% end %> diff --git a/app/views/versions/show.rhtml b/app/views/versions/show.rhtml index 18bc6bc..6b372ed 100644 --- a/app/views/versions/show.rhtml +++ b/app/views/versions/show.rhtml @@ -33,15 +33,17 @@ <%= render(:partial => "wiki/content", :locals => {:content => @version.wiki_page.content}) if @version.wiki_page %> <% issues = @version.fixed_issues.find(:all, - :include => [:status, :tracker, :priority], - :order => "#{Tracker.table_name}.position, #{Issue.table_name}.id") %> + :include => [:status, :tracker, :priority, :fixed_version], + :order => "#{Tracker.table_name}.position, #{Issue.table_name}.id") + issues ||= [] + issues.each do |i| + issues += i.ancestors if i.child? + end + issues.uniq! +%> <% if issues.size > 0 %> <% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index bcb8f57..e761518 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -165,6 +165,8 @@ en: error_issue_done_ratios_not_updated: "Issue done ratios not updated." error_workflow_copy_source: 'Please select a source tracker or role' error_workflow_copy_target: 'Please select target tracker(s) and role(s)' + error_issue_subtasks_cant_close_parent: "Can't close parent issue while one of the children is still open." + error_issue_subtasks_cant_select_lower_target_version: "Can't set target version of parent issue lower than any of the children." warning_attachments_not_saved: "{{count}} file(s) could not be saved." @@ -264,6 +266,7 @@ en: field_assignable: Issues can be assigned to this role field_redirect_existing_links: Redirect existing links field_estimated_hours: Estimated time + field_original_estimated_hours: Original estimated time field_column_names: Columns field_time_zone: Time zone field_searchable: Searchable @@ -276,6 +279,7 @@ en: field_content: Content field_group_by: Group results by field_sharing: Sharing + field_parent_issue: Child of setting_app_title: Application title setting_app_subtitle: Application subtitle @@ -329,6 +333,9 @@ en: setting_issue_done_ratio_issue_status: Use the issue status setting_start_of_week: Start calendars on setting_rest_api_enabled: Enable REST web service + setting_subissues_list_columns: Columns for subissues list + setting_delete_children: Delete children when parent destroyed + setting_reopened_parent_issue_status: Status applied to parent when reopening permission_add_project: Create project permission_add_subprojects: Create subprojects @@ -743,6 +750,11 @@ en: label_api_access_key: API access key label_missing_api_access_key: Missing an API access key label_api_access_key_created_on: "API access key created {{value}} ago" + label_view_option_parents_do_not_show: Never + label_view_option_parents_show_always: Always + label_view_option_parents_show_and_group: Organize by parent + label_issues_hierarchy: Issues hierarchy + label_view_option_show_parents: Show parents button_login: Login button_submit: Submit @@ -787,6 +799,7 @@ en: button_quote: Quote button_duplicate: Duplicate button_show: Show + button_add_subissue: Add sub-issue status_active: active status_registered: registered @@ -853,6 +866,7 @@ en: text_wiki_page_destroy_children: "Delete child pages and all their descendants" text_wiki_page_reassign_children: "Reassign child pages to this parent page" text_own_membership_delete_confirmation: "You are about to remove some or all of your permissions and may no longer be able to edit this project after that.\nAre you sure you want to continue?" + text_private: Private default_role_manager: Manager default_role_developper: Developer diff --git a/config/settings.yml b/config/settings.yml index cebfbb5..cf0764d 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -180,3 +180,15 @@ start_of_week: default: '' rest_api_enabled: default: 0 +delete_children: + default: 1 +subissues_list_columns: + serialized: true + default: + - id + - subject + - status + - start_date + - due_date +reopened_parent_issue_status: + default: '' diff --git a/db/migrate/20090115162651_add_queries_view_options.rb b/db/migrate/20090115162651_add_queries_view_options.rb new file mode 100644 index 0000000..cfd0377 --- /dev/null +++ b/db/migrate/20090115162651_add_queries_view_options.rb @@ -0,0 +1,9 @@ +class AddQueriesViewOptions < ActiveRecord::Migration + def self.up + add_column :queries, :view_options, :text + end + + def self.down + remove_column :queries, :view_options + end +end diff --git a/db/migrate/20090115162652_add_default_value_of_view_optoins_queries.rb b/db/migrate/20090115162652_add_default_value_of_view_optoins_queries.rb new file mode 100644 index 0000000..450f246 --- /dev/null +++ b/db/migrate/20090115162652_add_default_value_of_view_optoins_queries.rb @@ -0,0 +1,11 @@ +class AddDefaultValueOfViewOptoinsQueries < ActiveRecord::Migration + def self.up + Query.find(:all).each do |q| + q.view_options ||= { 'show_parents' => 'do_not_show' } + q.save! + end + end + + def self.down + end +end diff --git a/db/migrate/20090406213813_add_issues_parent_id_lft_and_rgt.rb b/db/migrate/20090406213813_add_issues_parent_id_lft_and_rgt.rb new file mode 100644 index 0000000..8a0ecf0 --- /dev/null +++ b/db/migrate/20090406213813_add_issues_parent_id_lft_and_rgt.rb @@ -0,0 +1,14 @@ +class AddIssuesParentIdLftAndRgt < ActiveRecord::Migration + + def self.up + add_column :issues, :parent_id, :integer, :default => nil + add_column :issues, :lft, :integer + add_column :issues, :rgt, :integer + end + + def self.down + remove_column :issues, :parent_id + remove_column :issues, :lft + remove_column :issues, :rgt + end +end diff --git a/db/migrate/20090406213899_issues_rebuild.rb b/db/migrate/20090406213899_issues_rebuild.rb new file mode 100644 index 0000000..0cad2b4 --- /dev/null +++ b/db/migrate/20090406213899_issues_rebuild.rb @@ -0,0 +1,15 @@ +# Need to assume Issues are valid in order to rebuild. +class Issue < ActiveRecord::Base + def valid? + true + end +end + +class IssuesRebuild < ActiveRecord::Migration + def self.up + Issue.rebuild! + end + + def self.down + end +end diff --git a/db/migrate/20091211204929_add_lft_rgt_indexes_to_issues.rb b/db/migrate/20091211204929_add_lft_rgt_indexes_to_issues.rb new file mode 100644 index 0000000..98e0dca --- /dev/null +++ b/db/migrate/20091211204929_add_lft_rgt_indexes_to_issues.rb @@ -0,0 +1,11 @@ +class AddLftRgtIndexesToIssues < ActiveRecord::Migration + def self.up + add_index :issues, :lft + add_index :issues, :rgt + end + + def self.down + remove_index :issues, :lft + remove_index :issues, :rgt + end +end diff --git a/db/migrate/20091211205222_add_indexes_to_issues_parent_id.rb b/db/migrate/20091211205222_add_indexes_to_issues_parent_id.rb new file mode 100644 index 0000000..e1edb63 --- /dev/null +++ b/db/migrate/20091211205222_add_indexes_to_issues_parent_id.rb @@ -0,0 +1,9 @@ +class AddIndexesToIssuesParentId < ActiveRecord::Migration + def self.up + add_index :issues, :parent_id + end + + def self.down + remove_index :issues, :parent_id + end +end diff --git a/lib/redmine.rb b/lib/redmine.rb index 0cf0cc4..f3c08d8 100644 --- a/lib/redmine.rb +++ b/lib/redmine.rb @@ -45,7 +45,7 @@ Redmine::AccessControl.map do |map| :reports => :issue_report} map.permission :add_issues, {:issues => [:new, :update_form]} map.permission :edit_issues, {:issues => [:edit, :reply, :bulk_edit, :update_form]} - map.permission :manage_issue_relations, {:issue_relations => [:new, :destroy]} + map.permission :manage_issue_relations, {:issue_relations => [:new, :destroy], :issues => [:add_subissue, :auto_complete_for_issue_parent]} map.permission :add_issue_notes, {:issues => [:edit, :reply]} map.permission :edit_issue_notes, {:journals => :edit}, :require => :loggedin map.permission :edit_own_issue_notes, {:journals => :edit}, :require => :loggedin diff --git a/public/images/contract.png b/public/images/contract.png new file mode 100644 index 0000000000000000000000000000000000000000..69566aeb07e3a90b815d07f667310cb4273d4600 GIT binary patch literal 290 zcmeAS@N?(olHy`uVBq!ia0vp^oFL4>1|%O$WD@{VEX7WqAsj$Z!;#Vfk}U9uEC#B- z4#JF18nY{af|4b!5hcO-X(i=}MX3zs<>h*rdD+Fui3O>8`9 zmSj&6PTZjRYvSea{5uqQN(zi_?9dfIG_OtJ0F!#%t^fv?Gfgl4xH1WC`zH~UU7n!6 zDWL18c0kvvMMWvvj~V7g+;)3befwUeDd$awC3aoOWj6U-%+f4x;vB74^t*5AlQG-= d^icgDrt6Pvb-&vSE(SW4!PC{xWt~$(69CxbYH|Po literal 0 HcmV?d00001 diff --git a/public/images/corner-dots.gif b/public/images/corner-dots.gif new file mode 100644 index 0000000000000000000000000000000000000000..470d7109951c83f0a024cd8e96bda02c93d96454 GIT binary patch literal 59 zcmZ?wbhEHb6ky3xxEEP)KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&neh&#Q1i z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4 zfg=2N-7=cNnjjOr{yriy6mMFgG#l znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya? z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0 z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$ z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4 z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i& z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01 z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3| zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F} z0001{Nkl7@c=G4L*RQ6%+5@6m_!_@o28Pft?S3D znP=>cIEdD6WSo#qgu$kmXP%y4zy)Dof|Et(>v|#VZ3zM_B98d(LIDtQ5X9tx8Aw1v zpWjPr?FI}Y3#HhOLMf_>%9X8*6L@29olH;)4>Kl@CLtSu)XHV#-|e_>3IiPihIe}Z l0nPP@b^!GG{$~E+cK}YdLLMYnRc-(P002ovPDHLkV1k-6a(@5- literal 0 HcmV?d00001 diff --git a/public/javascripts/application.js b/public/javascripts/application.js index 57419d0..fc27862 100644 --- a/public/javascripts/application.js +++ b/public/javascripts/application.js @@ -52,7 +52,7 @@ function addFileField() { d.type = "text"; d.name = "attachments[" + fileFieldCount + "][description]"; d.size = 60; - + p = document.getElementById("attachments_fields"); p.appendChild(document.createElement("br")); p.appendChild(f); @@ -194,6 +194,25 @@ function randomKey(size) { return key; } +function clearValues(dom_ids) { + dom_ids.each(function(dom_id) { + $(dom_id).value = ''; + }); +} + +function observeParentIssueField(url) { + new Ajax.Autocompleter('parent_issue', + 'parent_issue_candidates', + url, + { + minChars: 1, + frequency: 0.5, + paramName: 'issue_parent', + afterUpdateElement: function(element, value) { + document.getElementById('issue_parent_id').value = value.id; + }}); +} + /* shows and hides ajax indicator */ Ajax.Responders.register({ onCreate: function(){ diff --git a/public/stylesheets/application.css b/public/stylesheets/application.css index 3b95148..a6c236d 100644 --- a/public/stylesheets/application.css +++ b/public/stylesheets/application.css @@ -602,6 +602,7 @@ div.autocomplete ul li { border-bottom: 1px solid #ccc; border-left: 1px solid #ccc; border-right: 1px solid #ccc; + background-color: white; } div.autocomplete ul li span.informal { font-size: 80%; @@ -848,3 +849,43 @@ h2 img { vertical-align:middle; } #content { width: 99%; margin: 0; padding: 0; border: 0; background: #fff; overflow: visible !important;} #wiki_add_attachment { display:none; } } + +/***** Subtasks *****/ +.issue-subject-in-tree, .issue-subject-in-tree .issue-subject { + background-image: url(../images/corner-dots.gif); + background-repeat: no-repeat; + background-position: -3px center; +} + +/* Used to show issues which is not pass by filter, but should by + shown as parent for other issue. */ +.issue-unfiltered { + opacity: 0.5; + filter: alpha(opacity=50); +} + +.issue-emphasis { + font-weight: bold; +} + +.expanded-issue { + background-image: url(contract.png); + background-repeat: no-repeat; +} + +.contracted-issue { + background-image: url(expand.png); + background-repeat: no-repeat; +} + +.expand-icon, .contract-icon{ +/* position: absolute; */ + vertical-align: middle; +} + +/*text after the icon, which needs to be indented so that all its lines stay completely after the icon*/ +.issue-subject{ + padding-left: 1em; +} + +input#parent_issue {width: 80%;} \ No newline at end of file diff --git a/test/fixtures/issues.yml b/test/fixtures/issues.yml index 4b61b41..91b1003 100644 --- a/test/fixtures/issues.yml +++ b/test/fixtures/issues.yml @@ -15,6 +15,8 @@ issues_001: status_id: 1 start_date: <%= 1.day.ago.to_date.to_s(:db) %> due_date: <%= 10.day.from_now.to_date.to_s(:db) %> + lft: '1' + rgt: '2' issues_002: created_on: 2006-07-19 21:04:21 +02:00 project_id: 1 @@ -31,6 +33,8 @@ issues_002: status_id: 2 start_date: <%= 2.day.ago.to_date.to_s(:db) %> due_date: + lft: '3' + rgt: '4' issues_003: created_on: 2006-07-19 21:07:27 +02:00 project_id: 1 @@ -47,6 +51,8 @@ issues_003: status_id: 1 start_date: <%= 1.day.from_now.to_date.to_s(:db) %> due_date: <%= 40.day.ago.to_date.to_s(:db) %> + lft: '5' + rgt: '6' issues_004: created_on: <%= 5.days.ago.to_date.to_s(:db) %> project_id: 2 @@ -61,6 +67,8 @@ issues_004: assigned_to_id: 2 author_id: 2 status_id: 1 + lft: '7' + rgt: '8' issues_005: created_on: <%= 5.days.ago.to_date.to_s(:db) %> project_id: 3 @@ -75,6 +83,8 @@ issues_005: assigned_to_id: author_id: 2 status_id: 1 + lft: '9' + rgt: '10' issues_006: created_on: <%= 1.minute.ago.to_date.to_s(:db) %> project_id: 5 @@ -91,6 +101,8 @@ issues_006: status_id: 1 start_date: <%= Date.today.to_s(:db) %> due_date: <%= 1.days.from_now.to_date.to_s(:db) %> + lft: '11' + rgt: '12' issues_007: created_on: <%= 10.days.ago.to_date.to_s(:db) %> project_id: 1 @@ -108,6 +120,8 @@ issues_007: start_date: <%= 10.days.ago.to_s(:db) %> due_date: <%= Date.today.to_s(:db) %> lock_version: 0 + lft: '13' + rgt: '14' issues_008: created_on: <%= 10.days.ago.to_date.to_s(:db) %> project_id: 1 @@ -125,6 +139,8 @@ issues_008: start_date: due_date: lock_version: 0 + lft: '15' + rgt: '16' issues_009: created_on: <%= 1.minute.ago.to_date.to_s(:db) %> project_id: 5 @@ -141,6 +157,8 @@ issues_009: status_id: 1 start_date: <%= Date.today.to_s(:db) %> due_date: <%= 1.days.from_now.to_date.to_s(:db) %> + lft: '17' + rgt: '18' issues_010: created_on: <%= 1.minute.ago.to_date.to_s(:db) %> project_id: 5 @@ -157,6 +175,8 @@ issues_010: status_id: 1 start_date: <%= Date.today.to_s(:db) %> due_date: <%= 1.days.from_now.to_date.to_s(:db) %> + lft: '19' + rgt: '20' issues_011: created_on: <%= 3.days.ago.to_date.to_s(:db) %> project_id: 1 @@ -173,6 +193,8 @@ issues_011: status_id: 5 start_date: <%= 1.day.ago.to_date.to_s(:db) %> due_date: + lft: '21' + rgt: '22' issues_012: created_on: <%= 3.days.ago.to_date.to_s(:db) %> project_id: 1 @@ -189,6 +211,8 @@ issues_012: status_id: 5 start_date: <%= 1.day.ago.to_date.to_s(:db) %> due_date: + lft: '23' + rgt: '24' issues_013: created_on: <%= 5.days.ago.to_date.to_s(:db) %> project_id: 3 @@ -203,3 +227,148 @@ issues_013: assigned_to_id: author_id: 2 status_id: 1 + lft: '25' + rgt: '26' +issues_root: + created_on: <%= 10.days.ago.to_date.to_s(:db) %> + project_id: 1 + updated_on: <%= 10.days.ago.to_date.to_s(:db) %> + priority_id: 4 + subject: root + id: 14 + fixed_version_id: + category_id: + description: This is root of subissues. + tracker_id: 1 + assigned_to_id: + author_id: 2 + status_id: 1 + start_date: + due_date: + lock_version: 0 + lft: "27" + rgt: "38" + parent_id: + +issues_child001: + created_on: <%= 10.days.ago.to_date.to_s(:db) %> + project_id: 1 + updated_on: <%= 10.days.ago.to_date.to_s(:db) %> + priority_id: 4 + subject: child001 + id: 15 + fixed_version_id: + category_id: + description: This is child001 of root. + tracker_id: 1 + assigned_to_id: + author_id: 2 + status_id: 1 + start_date: + due_date: + lock_version: 0 + parent_id: 14 + lft: "28" + rgt: "33" +issues_child002: + created_on: <%= 10.days.ago.to_date.to_s(:db) %> + project_id: 1 + updated_on: <%= 10.days.ago.to_date.to_s(:db) %> + priority_id: 4 + subject: child002 + id: 16 + fixed_version_id: + category_id: + description: This is child002 of root. + tracker_id: 1 + assigned_to_id: + author_id: 2 + status_id: 1 + start_date: + due_date: + lock_version: 0 + parent_id: 14 + lft: "34" + rgt: "37" +issues_subchild001: + created_on: <%= 10.days.ago.to_date.to_s(:db) %> + project_id: 1 + updated_on: <%= 10.days.ago.to_date.to_s(:db) %> + priority_id: 4 + subject: subchild001 + id: 17 + fixed_version_id: + category_id: + description: This is subchild001 of child001. + tracker_id: 1 + assigned_to_id: + author_id: 2 + status_id: 1 + start_date: + due_date: + lock_version: 0 + parent_id: 15 + lft: "29" + rgt: "30" +issues_subchild002: + created_on: <%= 10.days.ago.to_date.to_s(:db) %> + project_id: 1 + updated_on: <%= 10.days.ago.to_date.to_s(:db) %> + priority_id: 4 + subject: subchild002 + id: 18 + fixed_version_id: + category_id: + description: This is subchild002 of child001. + tracker_id: 1 + assigned_to_id: + author_id: 2 + status_id: 1 + start_date: + due_date: + lock_version: 0 + parent_id: 15 + lft: "31" + rgt: "32" +issues_subchild003: + created_on: <%= 10.days.ago.to_date.to_s(:db) %> + project_id: 1 + updated_on: <%= 10.days.ago.to_date.to_s(:db) %> + priority_id: 4 + subject: subchild003 + id: 19 + fixed_version_id: + category_id: + description: This is subchild003 of child002. + tracker_id: 1 + assigned_to_id: + author_id: 2 + status_id: 1 + start_date: + due_date: <%= 10.days.from_now.to_date.to_s(:db) %> + lock_version: 0 + parent_id: 16 + lft: "35" + rgt: "36" + +issue_leaf_from_another_project: + created_on: <%= 10.days.ago.to_date.to_s(:db) %> + project_id: 2 + updated_on: <%= 10.days.ago.to_date.to_s(:db) %> + priority_id: 4 + subject: issue_leaf_from_another_project + id: 20 + fixed_version_id: 4 + category_id: 3 + description: This is issue from another project. + tracker_id: 1 + assigned_to_id: + author_id: 2 + status_id: 1 + start_date: + due_date: + lock_version: 0 + parent_id: nil + lft: "13" + rgt: "14" + diff --git a/test/fixtures/queries.yml b/test/fixtures/queries.yml index a49f82f..b5ec976 100644 --- a/test/fixtures/queries.yml +++ b/test/fixtures/queries.yml @@ -21,6 +21,9 @@ queries_001: user_id: 1 column_names: + view_options: | + --- + 'show_parents': 'do_not_show' queries_002: id: 2 project_id: 1 @@ -39,6 +42,9 @@ queries_002: user_id: 3 column_names: + view_options: | + --- + 'show_parents': 'do_not_show' queries_003: id: 3 project_id: @@ -53,6 +59,9 @@ queries_003: user_id: 3 column_names: + view_options: | + --- + 'show_parents': 'do_not_show' queries_004: id: 4 project_id: @@ -67,6 +76,9 @@ queries_004: user_id: 2 column_names: + view_options: | + --- + 'show_parents': 'do_not_show' queries_005: id: 5 project_id: @@ -87,6 +99,9 @@ queries_005: - desc - - tracker - asc + view_options: | + --- + 'show_parents': 'do_not_show' queries_006: id: 6 project_id: @@ -106,6 +121,9 @@ queries_006: --- - - priority - desc + view_options: | + --- + 'show_parents': 'do_not_show' queries_007: id: 7 project_id: 2 @@ -120,6 +138,9 @@ queries_007: user_id: 2 column_names: + view_options: | + --- + 'show_parents': 'do_not_show' queries_008: id: 8 project_id: 2 @@ -134,6 +155,9 @@ queries_008: user_id: 2 column_names: + view_options: | + --- + 'show_parents': 'do_not_show' queries_009: id: 9 project_id: @@ -153,4 +177,7 @@ queries_009: --- - - priority - desc + view_options: | + --- + 'show_parents': 'do_not_show' diff --git a/test/fixtures/versions.yml b/test/fixtures/versions.yml index 3b59a2f..7e840a5 100644 --- a/test/fixtures/versions.yml +++ b/test/fixtures/versions.yml @@ -69,3 +69,22 @@ versions_007: effective_date: status: open sharing: 'system' +onlinestore_1_0: + created_on: 2006-07-19 21:00:33 +02:00 + name: "1.0" + project_id: 2 + updated_on: 2006-07-19 21:00:33 +02:00 + id: 8 + description: Future version + effective_date: + status: open +versions_009: + created_on: 2006-07-19 21:00:33 +02:00 + name: "2.1" + project_id: 1 + updated_on: 2006-07-19 21:00:33 +02:00 + id: 9 + description: Future version + effective_date: + status: open + diff --git a/test/functional/issues_controller_test.rb b/test/functional/issues_controller_test.rb index 4b806de..3936a6a 100644 --- a/test/functional/issues_controller_test.rb +++ b/test/functional/issues_controller_test.rb @@ -267,6 +267,15 @@ class IssuesControllerTest < ActionController::TestCase end def test_gantt + parent_issue = Issue.find(14) + parent_issue.update_attributes(:start_date => 1.day.ago.to_date) + parent_issue.reload + assert_not_nil parent_issue.due_date + assert_nil parent_issue.read_attribute(:due_date) + + subissue = Issue.generate_for_project!(Project.find(1), :start_date => 1.day.ago.to_date, :due_date => 10.days.from_now.to_date) + subissue.move_to_child_of Issue.find(14) + get :gantt, :project_id => 1 assert_response :success assert_template 'gantt.rhtml' @@ -274,9 +283,12 @@ class IssuesControllerTest < ActionController::TestCase events = assigns(:gantt).events assert_not_nil events # Issue with start and due dates + assert events.include?(subissue) i = Issue.find(1) assert_not_nil i.due_date assert events.include?(Issue.find(1)) + # Parent issue with a child with a start and due date + assert events.include?(Issue.find(14)) # Issue with without due date but targeted to a version with date i = Issue.find(2) assert_nil i.due_date @@ -492,7 +504,38 @@ class IssuesControllerTest < ActionController::TestCase assert_tag :tag => 'div', :attributes => { :class => /error/ }, :content => /No tracker/ end - + + context "GET to :new" do + context "with a parent_id" do + setup do + @request.session[:user_id] = 3 + end + + should "set the parent issue" do + get :new, :project_id => 1, :issue => {:parent_id => 1} + assert_response :success + assert_template 'new' + assert_equal Issue.find(1), assigns(:parent_issue) + end + + should "not set the parent issue if the parameter points to a missing issue" do + get :new, :project_id => 1, :issue => {:parent_id => 1_000_000} + assert_response :success + assert_template 'new' + assert_equal nil, assigns(:parent_issue) + end + + should "not set the parent issue if the parameter points to an unauthorized issue" do + issue = Issue.generate_for_project!(Project.find(5)) + get :new, :project_id => 1, :issue => {:parent_id => issue.id} + assert_response :success + assert_template 'new' + assert_equal nil, assigns(:parent_issue) + end + end + + end + def test_update_new_form @request.session[:user_id] = 2 xhr :post, :update_form, :project_id => 1, @@ -924,6 +967,20 @@ class IssuesControllerTest < ActionController::TestCase :content => notes assert_tag :input, :attributes => { :name => 'time_entry[hours]', :value => "2z" } end + + def test_post_edit_with_parent_id_set_to_self + issue = Issue.find(1) + assert_equal nil, issue.parent + @request.session[:user_id] = 2 + + post :edit, + :id => 1, + :issue => { :parent_id => 1} + + assert_redirected_to :action => 'show', :id => '1' + issue.reload + assert_equal nil, issue.parent + end def test_post_edit_should_allow_fixed_version_to_be_set_to_a_subproject issue = Issue.find(2) @@ -1181,6 +1238,18 @@ class IssuesControllerTest < ActionController::TestCase :class => 'icon-del' } end + test 'context_menu with a parent issue' do + @request.session[:user_id] = 2 + @issue = Issue.generate_for_project!(Project.find(1), :subject => 'test') + @issue.move_to_child_of Issue.find(1) + + get :context_menu, :ids => [1] + + assert_response :success + assert_template 'context_menu' + assert_select 'a[class*=disabled]', :text => /0%/ + end + def test_context_menu_one_issue_by_anonymous get :context_menu, :ids => [1] assert_response :success @@ -1215,6 +1284,18 @@ class IssuesControllerTest < ActionController::TestCase :class => 'icon-del' } end + test 'context_menu with multiple issues and a parent issue' do + @request.session[:user_id] = 2 + @issue = Issue.generate_for_project!(Project.find(1), :subject => 'test') + @issue.move_to_child_of Issue.find(1) + + get :context_menu, :ids => [1,2] + + assert_response :success + assert_template 'context_menu' + assert_select 'a[class*=disabled]', :text => /0%/ + end + def test_context_menu_multiple_issues_of_different_project @request.session[:user_id] = 2 get :context_menu, :ids => [1, 2, 4] @@ -1281,4 +1362,267 @@ class IssuesControllerTest < ActionController::TestCase :child => {:tag => 'form', :child => {:tag => 'input', :attributes => {:name => 'issues', :type => 'hidden', :value => '1'}}} end + + def test_new_child_issue + child_issue_subject = "This is the test_new child issue" + parent_issue = issues( :issues_root) + @request.session[:user_id] = 2 + + post( :new, :project_id => 1, + :parent_issue => parent_issue, + :issue => {:tracker_id => 3, + :subject => child_issue_subject, + :description => 'This is the description', + :priority_id => 5, + :parent_id => parent_issue.id.to_s, + :estimated_hours => '', + :custom_field_values => {'2' => 'Value for field 2'}}) + child = Issue.find_by_subject( child_issue_subject) + + assert_redirected_to "issues/#{child.id}" + assert( child.parent == parent_issue, + "New child has Issue id=#{child.parent} as parent, not id=#{parent_issue}") + end + + def test_edit_issue_set_parent + parent_issue = issues( :issues_root) + moving_issue = issues( :issues_subchild003) + @request.session[:user_id] = 2 + + post( :edit, + :id => moving_issue.id, + :project_id => 1, + :parent_issue => parent_issue, + :issue => { + :parent_id => parent_issue.id + }) + assert_redirected_to :action => 'show', :id => moving_issue.id + assert moving_issue.reload.parent == parent_issue + end + + def test_move_child_to_root + parent = issues( :issues_root) + child = issues( :issues_child001) + + post( :edit, + :id => child.id, + :project_id => 1, + :issue => { + :parent_id => "", + }) + assert_redirected_to :controller => 'issues', :action => 'show', :id => child.id + assert child.reload.root? + end + + def test_add_subissue_should_redirect_to_action_new + @request.session[:user_id] = 2 + get( :add_subissue, :project_id => 1, + :issue => { + :tracker_id => 3, + :priority_id => 5, + :subject => "test_add_subissue", + :description => "test_add_subissue", + :estimated_hours => '' }, + :parent_issue_id => 1) + assert_redirected_to :controller => 'issues', :action => "new", :project_id => Project.find(1).to_param, :issue => {:parent_id => 1} + end + + def test_add_subissue_with_invalid_parent_id_should_render_404 + @request.session[:user_id] = 2 + get( :add_subissue, :project_id => 1, + :issue => { + :tracker_id => 3, + :subject => "test_add_subissue", + :description => "test_add_subissue", + :priority_id => 5, + :estimated_hours => ''}, + :parent_issue_id => 'invalid_id') + assert_template 'common/404', :status => 404 + end + + def test_add_subissue_with_a_private_parent_issue + @request.session[:user_id] = 3 # can't access Project 5 + get( :add_subissue, :project_id => 5, + :issue => { + :tracker_id => 3, + :priority_id => 5, + :subject => "test_add_private_subissue", + :description => "test_add_private_subissue", + :estimated_hours => '' }, + :parent_issue_id => 6) + assert_template 'common/404', :status => 404 + end + + def test_index_view_option_always_show_parents + @private_issue = Issue.find(4) + @child_issue = Issue.find(15) + @child_issue.move_to_child_of(@private_issue) + + @request.session[:user_id] = 3 + get( :index, + :project_id => 1, + :set_filter => 1, + :view_options => { :show_parents => "show_always"}) + assert_response :success + + assert_select 'table.issues' do + assert_select 'span.issue-subject-in-tree.issue-level-1', /child001/ + assert_select 'span.issue-subject-in-tree.issue-level-2', /subchild001/ + assert_select 'span', /root/ + + # Hidden issue on a private project + assert_select 'tr#issue-4', :count => 0 + assert_select 'td.subject', :text => /Issue on project 2/, :count => 0 + assert_select 'tr.private-issue .issue-subject', /Private/ + end + end + + def test_index_view_option_organize_by_parent + @private_issue = Issue.find(4) + @child_issue = Issue.find(15) + @child_issue.move_to_child_of(@private_issue) + + @request.session[:user_id] = 3 + get( :index, + :project_id => 1, + :set_filter => 1, + :view_options => { :show_parents => "organize_by_parent"}) + assert_response :success + + assert_select 'table.issues' do + assert_select '.issue-subject-in-tree.issue-level-1', /child001/ + assert_select '.issue-subject-in-tree.issue-level-1', /child002/ + assert_select '.issue-subject-in-tree.issue-level-2', /subchild001/ + assert_select '.issue-subject-in-tree.issue-level-2', /subchild002/ + assert_select 'tr.private-issue .issue-subject', /Private/ + end + end + + context "#auto_complete_for_issue_parent without authorization" do + setup do + @request.session[:user_id] = 3 + get :auto_complete_for_issue_parent, :project_id => 2 + end + + should_respond_with 403 + end + + context "#auto_complete_for_issue_parent with authorization" do + setup do + @request.session[:user_id] = 3 + end + + context "with a missing phrase" do + setup do + get :auto_complete_for_issue_parent, :project_id => 1 + end + + should_respond_with :success + + should "have an hidden content body" do + assert_select 'li[style*=?]', /display:none/ + end + end + + context "with an issue number for the project" do + setup do + get :auto_complete_for_issue_parent, :project_id => 1, :issue_parent => '3' + end + + should_respond_with :success + + should "have the matching issue in the content body" do + assert_select 'ul' do + assert_select 'li#3', /#{Issue.find(3).subject}/ + end + end + end + + context "with it's own issue number" do + setup do + get :auto_complete_for_issue_parent, :id => '3', :project_id => 1, :issue_parent => '3' + end + + should_respond_with :success + + should "not show it's own issue as a result" do + assert_select 'ul' do + assert_select 'li#3', :count => 0 + end + end + end + + context "with a cross project issue number" do + setup do + Setting.cross_project_issue_relations = '1' + get :auto_complete_for_issue_parent, :project_id => 1, :issue_parent => '5' + end + + should_respond_with :success + + should "have the matching issue in the content body" do + assert_select 'ul' do + assert_select 'li#5', /#{Issue.find(5).subject}/ + end + end + end + + context "searching by subject and description" do + setup do + get :auto_complete_for_issue_parent, :project_id => 1, :issue_parent => 'issue' + end + + should_respond_with :success + + should "have the matching issues in the content body" do + assert_select 'ul' do + assert_select 'li', :count => 7 + end + end + + end + + context "searching to unauthorized projects by issue id" do + setup do + get :auto_complete_for_issue_parent, :project_id => 1, :issue_parent => '4' + end + + should_respond_with :success + + should "not contain the unauthorized issues" do + assert_select 'ul' do + assert_select 'li#4', :count => 0 + end + end + + end + + context "searching to unauthorized projects by subject and description" do + setup do + get :auto_complete_for_issue_parent, :project_id => 1, :issue_parent => 'issue on project 2' + end + + should_respond_with :success + + should "not contain the unauthorized issues" do + assert_select 'ul' do + assert_select 'li', :count => 7 + assert_select 'li', :count => 0, :text => /issue on project 2/ + end + end + + end + + should "limit results to 10 records" do + Setting.cross_project_issue_relations = '1' + get :auto_complete_for_issue_parent, :project_id => 1, :issue_parent => 'e' + + assert_response :success + assert_select 'ul' do + assert_select 'li', :count => 10 + end + end + + + end end diff --git a/test/functional/projects_controller_test.rb b/test/functional/projects_controller_test.rb index 6c88e41..1ceb13b 100644 --- a/test/functional/projects_controller_test.rb +++ b/test/functional/projects_controller_test.rb @@ -390,6 +390,9 @@ class ProjectsControllerTest < ActionController::TestCase end def test_post_destroy + # Remove the an open issue on a version + Issue.destroy(20) + @request.session[:user_id] = 1 # admin post :destroy, :id => 1, :confirm => 1 assert_redirected_to 'admin/projects' @@ -612,6 +615,9 @@ class ProjectsControllerTest < ActionController::TestCase end def test_archive + # Remove the an open issue on a version + Issue.destroy(20) + @request.session[:user_id] = 1 # admin post :archive, :id => 1 assert_redirected_to 'admin/projects' diff --git a/test/functional/queries_controller_test.rb b/test/functional/queries_controller_test.rb index af2a86e..33c33ba 100644 --- a/test/functional/queries_controller_test.rb +++ b/test/functional/queries_controller_test.rb @@ -127,6 +127,23 @@ class QueriesControllerTest < ActionController::TestCase assert_equal [['due_date', 'desc'], ['tracker', 'asc']], query.sort_criteria end + def test_new_with_view_options + @request.session[:user_id] = 1 + post :new, + :confirm => '1', + :default_columns => '1', + :operators => {"status_id" => "o"}, + :values => {"status_id" => ["1"]}, + :query => {:name => "test_new_with_view_options", + :is_public => "1"}, + :view_options => {:show_parents => 'organize_by_parent'} + + query = Query.find_by_name("test_new_with_view_options") + assert_not_nil query + assert query.view_options.key?('show_parents') + assert_equal "organize_by_parent", query.view_options['show_parents'] + end + def test_get_edit_global_public_query @request.session[:user_id] = 1 get :edit, :id => 4 diff --git a/test/integration/projects_test.rb b/test/integration/projects_test.rb index 14175ea..c6250ba 100644 --- a/test/integration/projects_test.rb +++ b/test/integration/projects_test.rb @@ -21,6 +21,9 @@ class ProjectsTest < ActionController::IntegrationTest fixtures :projects, :users, :members def test_archive_project + # Remove the an open issue on a version + Issue.destroy(20) + subproject = Project.find(1).children.first log_user("admin", "admin") get "admin/projects" diff --git a/test/unit/enumeration_test.rb b/test/unit/enumeration_test.rb index d9d3307..4f5a1f2 100644 --- a/test/unit/enumeration_test.rb +++ b/test/unit/enumeration_test.rb @@ -25,7 +25,7 @@ class EnumerationTest < ActiveSupport::TestCase def test_objects_count # low priority - assert_equal 6, Enumeration.find(4).objects_count + assert_equal 13, Enumeration.find(4).objects_count # urgent assert_equal 0, Enumeration.find(7).objects_count end @@ -79,7 +79,7 @@ class EnumerationTest < ActiveSupport::TestCase def test_destroy_with_reassign Enumeration.find(4).destroy(Enumeration.find(6)) assert_nil Issue.find(:first, :conditions => {:priority_id => 4}) - assert_equal 6, Enumeration.find(6).objects_count + assert_equal 13, Enumeration.find(6).objects_count end def test_should_be_customizable diff --git a/test/unit/issue_priority_test.rb b/test/unit/issue_priority_test.rb index 51a6b82..0f43888 100644 --- a/test/unit/issue_priority_test.rb +++ b/test/unit/issue_priority_test.rb @@ -26,7 +26,7 @@ class IssuePriorityTest < ActiveSupport::TestCase def test_objects_count # low priority - assert_equal 6, IssuePriority.find(4).objects_count + assert_equal 13, IssuePriority.find(4).objects_count # urgent assert_equal 0, IssuePriority.find(7).objects_count end diff --git a/test/unit/issue_test.rb b/test/unit/issue_test.rb index b22b05c..96009fc 100644 --- a/test/unit/issue_test.rb +++ b/test/unit/issue_test.rb @@ -27,6 +27,10 @@ class IssueTest < ActiveSupport::TestCase :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values, :time_entries + def setup + Setting.reopened_parent_issue_status = '' + end + def test_create issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create', :description => 'IssueTest#test_create', :estimated_hours => '1:30') assert issue.save @@ -556,6 +560,40 @@ class IssueTest < ActiveSupport::TestCase assert_equal 50, @issue.done_ratio end end + + context "on a leaf" do + should "return the attribute" do + issue = Issue.generate_for_project!(Project.find(1), :subject => 'is a leaf', :done_ratio => 50) + assert_equal 50, issue.done_ratio + end + end + + context "not a leaf (has child issues)" do + should "total the estimated hours of the children" do + @issue1 = Issue.generate_for_project!(Project.find(1), :subject => 'parent', :done_ratio => 10, :start_date => '2009-12-07', :due_date => '2009-12-17') + @issue2 = Issue.generate_for_project!(Project.find(1), :subject => 'parent 2', :done_ratio => 20, :start_date => '2009-12-07', :due_date => '2009-12-17') + @issue3 = Issue.generate_for_project!(Project.find(1), :subject => 'c', :done_ratio => 30, :start_date => '2009-12-07', :due_date => '2009-12-17') + @issue4 = Issue.generate_for_project!(Project.find(1), :subject => 'c', :done_ratio => 40, :start_date => '2009-12-07', :due_date => '2009-12-17') + @issue5 = Issue.generate_for_project!(Project.find(1), :subject => 'c', :done_ratio => 50, :start_date => '2009-12-07', :due_date => '2009-12-17') + + @issue2.move_to_child_of @issue1 + @issue3.move_to_child_of @issue2 + @issue4.move_to_child_of @issue2 + @issue5.move_to_child_of @issue2 + + [@issue1, @issue2, @issue3, @issue4, @issue5].each {|i| i.reload } + + assert_equal 50, @issue5.done_ratio # Leaf + assert_equal 40, @issue4.done_ratio # Leaf + assert_equal 30, @issue3.done_ratio # Leaf + # Parent with three. Children are 12 actual days / 30 planned days + assert_equal 40, @issue2.done_ratio + # Grantparent with three. Children are 12 actual days / 30 + # planned days. Issue2 doesn't add anything since it's just a + # parent task for the leafs + assert_equal 40, @issue1.done_ratio + end + end end context "#update_done_ratio_from_issue_status" do @@ -589,4 +627,280 @@ class IssueTest < ActiveSupport::TestCase end end end + + context "#do_subtasks_hooks" do + should "update target version of parent issue" do + create_family_of_issues 'Target version of parent updates test' + + version_2_0 = Version.find( versions( :versions_003).id) + version_2_1 = Version.find( versions( :versions_009).id) + + # set target version for child + @issue2.fixed_version = version_2_0 + assert @issue2.save + assert @issue1.reload.fixed_version == @issue2.fixed_version + + # set target version for child of child larger than target version + # of child. so, target version of parents should be updated. + @issue3.fixed_version = version_2_1 + assert @issue3.save + assert @issue1.reload.fixed_version == @issue3.fixed_version + end + + should "not allow closing the parent issue while one of the children is open" do + create_family_of_issues 'Closing parent issue when some children is open test.' + + closed_status = issue_statuses( :issue_statuses_005) + @issue3.status = closed_status + assert @issue3.save + + assert_raise ActiveRecord::RecordInvalid do + @issue1.reload.status = closed_status + @issue1.save! + end + + end + + + context "change the status of parent when some children are open" do + should "to the default status if the Reopened Parent Issue Status setting is blank" do + with_settings :reopened_parent_issue_status => "" do + create_family_of_issues 'Changing status of parent from closed to open when some of children is open.' + + open_status = issue_statuses( :issue_statuses_001) + closed_status = issue_statuses( :issue_statuses_005) + + @issue3.status = closed_status + @issue2.status = closed_status + @issue1.status = closed_status + assert @issue3.save + assert @issue2.save + assert @issue1.save + assert @issue1.reload.closed? + assert @issue2.reload.closed? + assert @issue3.reload.closed? + + # set status of children to open status. this should update status + # of parent and set it to open state. + @issue2.status = open_status + assert @issue2.save + assert !@issue2.reload.closed? + assert !@issue1.reload.closed? + assert_equal IssueStatus.default, @issue1.status + end + end + + should "to the configured Reopened Parent Issue Status" do + configured_status = IssueStatus.find(4) + assert !configured_status.is_closed? + + with_settings :reopened_parent_issue_status => configured_status.id.to_s do + create_family_of_issues 'Changing status of parent from closed to open when some of children is open.' + + open_status = issue_statuses( :issue_statuses_001) + closed_status = issue_statuses( :issue_statuses_005) + + @issue3.status = closed_status + @issue2.status = closed_status + @issue1.status = closed_status + assert @issue3.save + assert @issue2.save + assert @issue1.save + assert @issue1.reload.closed? + assert @issue2.reload.closed? + assert @issue3.reload.closed? + + # set status of children to open status. this should update status + # of parent and set it to open state. + @issue2.status = open_status + assert @issue2.save + assert !@issue2.reload.closed? + assert !@issue1.reload.closed? + assert_equal configured_status, @issue1.status + end + end + end + + should "update Target Version of parent if the children have a bigger target version" do + create_family_of_issues 'Update target version of parent if children have bigger target version.' + + # set parent version to 1. + @issue1.fixed_version = versions( :versions_003) + assert @issue1.save + assert @issue1.reload.fixed_version == versions( :versions_003) + + # set children to version higher that parent. + @issue2.fixed_version = versions( :versions_009) + assert @issue2.save + assert @issue1.reload.fixed_version == versions( :versions_009) + end + + should "set target version of parent if children have a target version" do + create_family_of_issues 'Update target version of parent if children have a target version.' + + @issue2.fixed_version = versions( :versions_003) + assert @issue2.save, @issue2.errors.full_messages + assert @issue2.reload.fixed_version == versions( :versions_003) + assert @issue1.reload.fixed_version == @issue2.fixed_version + end + + should "not allow to set target version of parent lower than any of the children" do + create_family_of_issues 'Not allowing to set target version of parent lower than any of the children.' + + [ @issue1, @issue2, @issue3 ].each do |issue| + issue.update_attribute :fixed_version, versions( :versions_002) + end + + assert_raise ActiveRecord::RecordInvalid do + @issue1.fixed_version = versions( :versions_001) + @issue1.save! + end + end + + should "not set target version of parent if child on another project" do + create_family_of_issues 'Should not set target version of parnet if child on another project.' + + @issue2.fixed_version = versions( :versions_003) + assert @issue2.save + assert @issue1.reload.fixed_version == versions( :versions_003) + + online_store = projects( :projects_002) + assert @issue2.move_to( online_store) + # Need to retrieve the record again to bust the + # @assignable_versions cache + @issue2 = Issue.find(@issue2.id) + @issue2.fixed_version = versions( :onlinestore_1_0) + assert @issue2.save, @issue2.errors.full_messages + assert @issue1.reload.fixed_version == versions( :versions_003) + assert @issue2.reload.fixed_version == versions( :onlinestore_1_0) + end + + end + + def test_should_update_due_to_date_if_target_version_is_set_but_due_to_is_not + @issue = Issue.new( :project_id => 1, :tracker_id => 1, + :author_id => 1, :status_id => 1, + :priority => IssuePriority.first, + :subject => 'issue for test hook which set due_to when sets target version.', + :description => 'issue for test hook which set due_to when sets target version.') + + assert @issue.save + assert @issue.reload.due_date == nil + @issue.fixed_version = versions(:versions_003) + assert @issue.save + assert @issue.reload.due_date == @issue.reload.fixed_version.due_date + end + + def test_settings_delete_children_on + with_settings :delete_children => "1" do + @root = issues( :issues_root) + children_before_delete = @root.children.clone + assert @root.destroy, "failed to destroy parent issue" + assert_raise ActiveRecord::RecordNotFound do + children_before_delete.each( &:reload) + end + end + end + + def test_settings_delete_children_off + with_settings :delete_children => "0" do + @root = issues( :issues_root) + children_before_delete = @root.children.clone + assert @root.destroy, "failed to destroy parent issue" + assert_nothing_raised do + children_before_delete.each( &:reload) + end + end + end + + def test_should_not_change_target_version_if_children_from_another_project + with_settings :cross_project_issue_relations => true do + @root = issues( :issues_root) + @issue_another_project = issues( :issue_leaf_from_another_project) + + older_version = versions( :versions_001) + newer_version = versions( :onlinestore_1_0) + @root.update_attribute :fixed_version, older_version + @issue_another_project.move_to_child_of @root + assert @root.reload.fixed_version == older_version + end + end + + context "Issue#estimated_hours" do + context "on a leaf" do + should "return the attribute" do + issue = Issue.generate_for_project!(Project.find(1), :subject => 'is a leaf', :estimated_hours => 10.0) + assert_equal 10, issue.estimated_hours + end + end + + context "not a leaf (has child issues)" do + should "total the estimated hours of the children" do + @issue1 = Issue.generate_for_project!(Project.find(1), :subject => 'parent', :estimated_hours => 5.0) + @issue2 = Issue.generate_for_project!(Project.find(1), :subject => 'parent 2', :estimated_hours => 10.0) + @issue3 = Issue.generate_for_project!(Project.find(1), :subject => 'parent', :estimated_hours => 20.0) + @issue4 = Issue.generate_for_project!(Project.find(1), :subject => 'parent', :estimated_hours => 20.0) + @issue5 = Issue.generate_for_project!(Project.find(1), :subject => 'parent', :estimated_hours => nil) + + @issue2.move_to_child_of @issue1 + @issue3.move_to_child_of @issue2 + @issue4.move_to_child_of @issue2 + @issue5.move_to_child_of @issue2 + + [@issue1, @issue2, @issue3, @issue4, @issue5].each {|i| i.reload } + + assert_equal nil, @issue5.estimated_hours # Leaf + assert_equal 20, @issue4.estimated_hours # Leaf + assert_equal 20, @issue3.estimated_hours # Leaf + assert_equal 40, @issue2.estimated_hours # Parent with two + assert_equal 40, @issue1.estimated_hours # Parent with two + + end + end + end + + context "#parent_id=" do + setup do + @issue1 = Issue.generate_for_project!(Project.find(1), :subject => 'parent') + @issue2 = Issue.generate_for_project!(Project.find(1), :subject => 'child') + end + + should "set the parent issue by id" do + @issue1.parent_id=(@issue2.id) + + assert_equal @issue2.id, @issue1.parent_id + assert_equal @issue2, @issue1.parent + end + + should "not allow setting the parent issue to itself" do + @issue1.parent_id=(@issue1.id) + + assert_equal nil, @issue1.parent_id + assert_equal nil, @issue1.parent + + end + end + + private + + def create_family_of_issues( subject) + project = Project.find(1) + @issue1 = Issue.generate_for_project!(project, :subject => subject, :description => subject) + @issue2 = Issue.generate_for_project!(project, :subject => subject, :description => subject) + @issue3 = Issue.generate_for_project!(project, :subject => subject, :description => subject) + + # 2 is a child of 1 + @issue2.move_to_child_of @issue1 + + # And 3 is a child of 2 + @issue3.move_to_child_of @issue2 + + @issue1.reload + @issue2.reload + @issue3.reload + + assert @issue2.parent == @issue1 + assert @issue2.children.include?( @issue3) + end + end diff --git a/test/unit/project_test.rb b/test/unit/project_test.rb index c1fc843..cfd04ca 100644 --- a/test/unit/project_test.rb +++ b/test/unit/project_test.rb @@ -97,6 +97,9 @@ class ProjectTest < ActiveSupport::TestCase end def test_archive + # Remove the an open issue on a version + Issue.destroy(20) + user = @ecookbook.members.first.user @ecookbook.archive @ecookbook.reload @@ -120,6 +123,9 @@ class ProjectTest < ActiveSupport::TestCase end def test_unarchive + # Remove the an open issue on a version + Issue.destroy(20) + user = @ecookbook.members.first.user @ecookbook.archive # A subproject of an archived project can not be unarchived @@ -136,6 +142,9 @@ class ProjectTest < ActiveSupport::TestCase end def test_destroy + # Remove the an open issue on a version + Issue.destroy(20) + # 2 active members assert_equal 2, @ecookbook.members.size # and 1 is locked @@ -237,7 +246,7 @@ class ProjectTest < ActiveSupport::TestCase # Move project out of the issue's hierarchy moved_project = Project.find(3) - moved_project.set_parent!(Project.find(2)) + assert moved_project.set_parent!(Project.find(2)) parent_issue.reload issue_with_local_fixed_version.reload issue_with_hierarchy_fixed_version.reload @@ -309,12 +318,17 @@ class ProjectTest < ActiveSupport::TestCase end def test_rolled_up_trackers_should_ignore_archived_subprojects + # Remove the an open issue on a version + Issue.destroy(20) + parent = Project.find(1) parent.trackers = Tracker.find([1,2]) child = parent.children.find(3) child.trackers = Tracker.find([1,3]) - parent.children.each(&:archive) - + parent.children.each do |child| + assert child.archive, "Project #{child} did not archive" + end + assert_equal [1,2], parent.rolled_up_trackers.collect(&:id) end @@ -373,26 +387,29 @@ class ProjectTest < ActiveSupport::TestCase child = parent.children.find(3) private_child = parent.children.find(5) - assert_equal [1,2,3], parent.version_ids.sort + assert_equal [1,2,3,9], parent.version_ids.sort assert_equal [4], child.version_ids assert_equal [6], private_child.version_ids assert_equal [7], Version.find_all_by_sharing('system').collect(&:id) - assert_equal 6, parent.shared_versions.size + assert_equal 7, parent.shared_versions.size parent.shared_versions.each do |version| assert_kind_of Version, version end - assert_equal [1,2,3,4,6,7], parent.shared_versions.collect(&:id).sort + assert_equal [1,2,3,4,6,7,9], parent.shared_versions.collect(&:id).sort end def test_shared_versions_should_ignore_archived_subprojects + # Remove the an open issue on a version + Issue.destroy(20) + parent = Project.find(1) child = parent.children.find(3) - child.archive + assert child.archive parent.reload - assert_equal [1,2,3], parent.version_ids.sort + assert_equal [1,2,3,9], parent.version_ids.sort assert_equal [4], child.version_ids assert !parent.shared_versions.collect(&:id).include?(4) end @@ -402,12 +419,12 @@ class ProjectTest < ActiveSupport::TestCase parent = Project.find(1) child = parent.children.find(5) - assert_equal [1,2,3], parent.version_ids.sort + assert_equal [1,2,3,9], parent.version_ids.sort assert_equal [6], child.version_ids versions = parent.shared_versions.visible(user) - assert_equal 4, versions.size + assert_equal 5, versions.size versions.each do |version| assert_kind_of Version, version end @@ -567,7 +584,6 @@ class ProjectTest < ActiveSupport::TestCase assert_equal @source_project.issues.size, @project.issues.size @project.issues.each do |issue| assert issue.valid? - assert ! issue.assigned_to.blank? assert_equal @project, issue.project end @@ -581,7 +597,7 @@ class ProjectTest < ActiveSupport::TestCase User.current = User.find(1) assigned_version = Version.generate!(:name => "Assigned Issues", :status => 'open') @source_project.versions << assigned_version - assert_equal 3, @source_project.versions.size + assert_equal 4, @source_project.versions.size Issue.generate_for_project!(@source_project, :fixed_version_id => assigned_version.id, :subject => "change the new issues to use the copied version", diff --git a/test/unit/query_test.rb b/test/unit/query_test.rb index 2044747..829948f 100644 --- a/test/unit/query_test.rb +++ b/test/unit/query_test.rb @@ -63,7 +63,7 @@ class QueryTest < ActiveSupport::TestCase query.add_filter('estimated_hours', '!*', ['']) issues = find_issues_with_query(query) assert !issues.empty? - assert issues.all? {|i| !i.estimated_hours} + assert issues.all? {|i| !i.read_attribute(:estimated_hours)} end def test_operator_all -- 1.6.5