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 effective_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 %>
-
- <% 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 @@
<% (0..10).map{|x|x*10}.each do |p| -%>
- <%= context_menu_link "#{p}%", {:controller => 'issues', :action => 'bulk_edit', :ids => @issues.collect(&:id), 'done_ratio' => p, :back_to => @back}, :method => :post,
- :selected => (@issue && p == @issue.done_ratio), :disabled => !@can[:edit] %>
+ :selected => (@issue && p == @issue.done_ratio), :disabled => (!@can[:edit] || !@issues.all? {|i| i.leaf? }) %>
<% end -%>
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 %>