]> source.dussan.org Git - redmine.git/commitdiff
Display totals for each group on grouped queries (#1561).
authorJean-Philippe Lang <jp_lang@yahoo.fr>
Fri, 9 Oct 2015 09:02:11 +0000 (09:02 +0000)
committerJean-Philippe Lang <jp_lang@yahoo.fr>
Fri, 9 Oct 2015 09:02:11 +0000 (09:02 +0000)
git-svn-id: http://svn.redmine.org/redmine/trunk@14665 e93f8b46-1217-0410-a6f0-8f06a7374b81

app/helpers/issues_helper.rb
app/helpers/queries_helper.rb
app/models/issue_query.rb
app/models/query.rb
app/views/issues/_list.html.erb
public/stylesheets/application.css
test/functional/issues_controller_test.rb
test/unit/query_test.rb

index d803df0cc418ec5bb33254e8cd1fac2821bca124..06e5f87bccd6f130e1d99182611b8ffd57e92bd4 100644 (file)
@@ -34,6 +34,10 @@ module IssuesHelper
 
   def grouped_issue_list(issues, query, issue_count_by_group, &block)
     previous_group, first = false, true
+    totals_by_group = query.totalable_columns.inject({}) do |h, column|
+      h[column] = query.total_by_group_for(column)
+      h
+    end
     issue_list(issues) do |issue, level|
       group_name = group_count = nil
       if query.grouped? && ((group = query.group_by_column.value(issue)) != previous_group || first)
@@ -44,8 +48,9 @@ module IssuesHelper
         end
         group_name ||= ""
         group_count = issue_count_by_group[group]
+        group_totals = totals_by_group.map {|column, t| total_tag(column, t[group] || 0)}.join(" ").html_safe
       end
-      yield issue, level, group_name, group_count
+      yield issue, level, group_name, group_count, group_totals
       previous_group, first = group, false
     end
   end
index 3c6049acd0e71726c2230dfe655251a7cf5619da..f817265dd00127d82758ac6edcaabac7f97c49d3 100644 (file)
@@ -108,13 +108,17 @@ module QueriesHelper
   def render_query_totals(query)
     return unless query.totalable_columns.present?
     totals = query.totalable_columns.map do |column|
-      label = content_tag('span', "#{column.caption}:")
-      value = content_tag('span', " #{query.total_for(column)}", :class => 'value')
-      content_tag('span', label + " " + value, :class => "total-for-#{column.name.to_s.dasherize}")
+      total_tag(column, query.total_for(column))
     end
     content_tag('p', totals.join(" ").html_safe, :class => "query-totals")
   end
 
+  def total_tag(column, value)
+    label = content_tag('span', "#{column.caption}:")
+    value = content_tag('span', format_object(value), :class => 'value')
+    content_tag('span', label + " " + value, :class => "total-for-#{column.name.to_s.dasherize}")
+  end
+
   def column_header(column)
     column.sortable ? sort_header_tag(column.name.to_s, :caption => column.caption,
                                                         :default_order => column.default_order) :
index 36f47ef0e8e3b869d0baec8bf6a0777dc6b71bb4..45aacdffa855ac86346e6a6028de03b1d074ab26 100644 (file)
@@ -303,7 +303,6 @@ class IssueQuery < Query
   def base_scope
     Issue.visible.joins(:status, :project).where(statement)
   end
-  private :base_scope
 
   # Returns the issue count
   def issue_count
@@ -312,55 +311,21 @@ class IssueQuery < Query
     raise StatementInvalid.new(e.message)
   end
 
-  # Returns sum of all the issue's estimated_hours
-  def total_for_estimated_hours
-    base_scope.sum(:estimated_hours).to_f.round(2)
-  end
-
-  # Returns sum of all the issue's time entries hours
-  def total_for_spent_hours
-    base_scope.joins(:time_entries).sum("#{TimeEntry.table_name}.hours").to_f.round(2)
-  end
-
-  def total_for_custom_field(custom_field)
-    base_scope.joins(:custom_values).
-      where(:custom_values => {:custom_field_id => custom_field.id}).
-      where.not(:custom_values => {:value => ''}).
-      sum("CAST(#{CustomValue.table_name}.value AS decimal(30,3))")
-  end
-  private :total_for_custom_field
-
-  def total_for_float_custom_field(custom_field)
-    total_for_custom_field(custom_field).to_f
+  # Returns the issue count by group or nil if query is not grouped
+  def issue_count_by_group
+    grouped_query do |scope|
+      scope.count
+    end
   end
 
-  def total_for_int_custom_field(custom_field)
-    total_for_custom_field(custom_field).to_i
+  # Returns sum of all the issue's estimated_hours
+  def total_for_estimated_hours(scope)
+    scope.sum(:estimated_hours)
   end
 
-  # Returns the issue count by group or nil if query is not grouped
-  def issue_count_by_group
-    r = nil
-    if grouped?
-      begin
-        # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
-        r = Issue.visible.
-          joins(:status, :project).
-          where(statement).
-          joins(joins_for_order_statement(group_by_statement)).
-          group(group_by_statement).
-          count
-      rescue ActiveRecord::RecordNotFound
-        r = {nil => issue_count}
-      end
-      c = group_by_column
-      if c.is_a?(QueryCustomFieldColumn)
-        r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
-      end
-    end
-    r
-  rescue ::ActiveRecord::StatementInvalid => e
-    raise StatementInvalid.new(e.message)
+  # Returns sum of all the issue's time entries hours
+  def total_for_spent_hours(scope)
+    scope.joins(:time_entries).sum("#{TimeEntry.table_name}.hours")
   end
 
   # Returns the issues
index ac7b7cd1c849a7fb22776ece5911a4c8e0f67978..452b6d00c7a8819a97ed183597ac9d8eaa2f58f0 100644 (file)
@@ -632,21 +632,99 @@ class Query < ActiveRecord::Base
 
   # Returns the sum of values for the given column
   def total_for(column)
+    total_with_scope(column, base_scope)
+  end
+
+  # Returns a hash of the sum of the given column for each group,
+  # or nil if the query is not grouped
+  def total_by_group_for(column)
+    grouped_query do |scope|
+      total_with_scope(column, scope)
+    end
+  end
+
+  def totals
+    totals = totalable_columns.map {|column| [column, total_for(column)]}
+    yield totals if block_given?
+    totals
+  end
+
+  def totals_by_group
+    totals = totalable_columns.map {|column| [column, total_by_group_for(column)]}
+    yield totals if block_given?
+    totals
+  end
+
+  private
+
+  def grouped_query(&block)
+    r = nil
+    if grouped?
+      begin
+        # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
+        r = yield base_group_scope
+      rescue ActiveRecord::RecordNotFound
+        r = {nil => yield(base_scope)}
+      end
+      c = group_by_column
+      if c.is_a?(QueryCustomFieldColumn)
+        r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
+      end
+    end
+    r
+  rescue ::ActiveRecord::StatementInvalid => e
+    raise StatementInvalid.new(e.message)
+  end
+
+  def total_with_scope(column, scope)
     unless column.is_a?(QueryColumn)
       column = column.to_sym
       column = available_totalable_columns.detect {|c| c.name == column}
     end
     if column.is_a?(QueryCustomFieldColumn)
       custom_field = column.custom_field
-      send "total_for_#{custom_field.field_format}_custom_field", custom_field
+      send "total_for_#{custom_field.field_format}_custom_field", custom_field, scope
     else
-      send "total_for_#{column.name}"
+      send "total_for_#{column.name}", scope
     end
   rescue ::ActiveRecord::StatementInvalid => e
     raise StatementInvalid.new(e.message)
   end
 
-  private
+  def base_scope
+    raise "unimplemented"
+  end
+
+  def base_group_scope
+    base_scope.
+      joins(joins_for_order_statement(group_by_statement)).
+      group(group_by_statement)
+  end
+
+  def total_for_float_custom_field(custom_field, scope)
+    total_for_custom_field(custom_field, scope) {|t| t.to_f.round(2)}
+  end
+
+  def total_for_int_custom_field(custom_field, scope)
+    total_for_custom_field(custom_field, scope) {|t| t.to_i}
+  end
+
+  def total_for_custom_field(custom_field, scope)
+    total = scope.joins(:custom_values).
+      where(:custom_values => {:custom_field_id => custom_field.id}).
+      where.not(:custom_values => {:value => ''}).
+      sum("CAST(#{CustomValue.table_name}.value AS decimal(30,3))")
+
+    if block_given?
+      if total.is_a?(Hash)
+        total.keys.each {|k| total[k] = yield total[k]}
+      else
+        total = yield total
+      end
+    end
+
+    total
+  end
 
   def sql_for_custom_field(field, operator, value, custom_field_id)
     db_table = CustomValue.table_name
index c6c7613b48ba6d584713eb9ef8226bd02e7d5155..57ae6b2bc550d6f53a8f48a7009e442e34a5aa31 100644 (file)
     </tr>
   </thead>
   <tbody>
-  <% grouped_issue_list(issues, @query, @issue_count_by_group) do |issue, level, group_name, group_count| -%>
+  <% grouped_issue_list(issues, @query, @issue_count_by_group) do |issue, level, group_name, group_count, group_totals| -%>
   <% if group_name %>
     <% reset_cycle %>
     <tr class="group open">
       <td colspan="<%= query.inline_columns.size + 2 %>">
         <span class="expander" onclick="toggleRowGroup(this);">&nbsp;</span>
-        <%= group_name %> <span class="count"><%= group_count %></span>
+        <span class="name"><%= group_name %></span> <span class="count"><%= group_count %></span> <span class="totals"><%= group_totals %></span>
         <%= link_to_function("#{l(:button_collapse_all)}/#{l(:button_expand_all)}",
                              "toggleAllRowGroups(this)", :class => 'toggle-all') %>
       </td>
index b5df98a9073c9e539ef30998a2db48fe050f1814..f66b96b1b53548962fcfd1f2518c4b2441393960 100644 (file)
@@ -231,9 +231,12 @@ table.plugins span.name { font-weight: bold; display: block; margin-bottom: 6px;
 table.plugins span.description { display: block; font-size: 0.9em; }
 table.plugins span.url { display: block; font-size: 0.9em; }
 
-table.list tbody tr.group td { padding: 0.8em 0 0.5em 0.3em; font-weight: bold; border-bottom: 1px solid #ccc; text-align:left; }
-table.list tbody tr.group span.count {position:relative; top:-1px; color:#fff; font-size:10px; background:#9DB9D5; padding:0px 6px 1px 6px; border-radius:3px; margin-left:4px;}
-tr.group a.toggle-all { color: #aaa; font-size: 80%; font-weight: normal; display:none;}
+tr.group td { padding: 0.8em 0 0.5em 0.3em; border-bottom: 1px solid #ccc; text-align:left; }
+tr.group span.name {font-weight:bold;}
+tr.group span.count {font-weight:bold; position:relative; top:-1px; color:#fff; font-size:10px; background:#9DB9D5; padding:0px 6px 1px 6px; border-radius:3px; margin-left:4px;}
+tr.group span.totals {color: #aaa; font-size: 80%;}
+tr.group span.totals .value {font-weight:bold; color:#777;}
+tr.group a.toggle-all { color: #aaa; font-size: 80%; display:none; float:right; margin-right:4px;}
 tr.group:hover a.toggle-all { display:inline;}
 a.toggle-all:hover {text-decoration:none;}
 
index 18c82aef1c9c4dc4f33eeffd18d20a535949a998..158ba4316c7ef6ad1e54e550c79254ab8ebdf247 100644 (file)
@@ -953,10 +953,32 @@ class IssuesControllerTest < ActionController::TestCase
     get :index, :t => %w(estimated_hours)
     assert_response :success
     assert_select '.query-totals'
-    assert_select '.total-for-estimated-hours span.value', :text => '6.6'
+    assert_select '.total-for-estimated-hours span.value', :text => '6.60'
     assert_select 'input[type=checkbox][name=?][value=estimated_hours][checked=checked]', 't[]'
   end
 
+  def test_index_with_grouped_query_and_estimated_hours_total
+    Issue.delete_all
+    Issue.generate!(:estimated_hours => 5.5, :category_id => 1)
+    Issue.generate!(:estimated_hours => 2.3, :category_id => 1)
+    Issue.generate!(:estimated_hours => 1.1, :category_id => 2)
+    Issue.generate!(:estimated_hours => 4.6)
+
+    get :index, :t => %w(estimated_hours), :group_by => 'category'
+    assert_response :success
+    assert_select '.query-totals'
+    assert_select '.query-totals .total-for-estimated-hours span.value', :text => '13.50'
+    assert_select 'tr.group', :text => /Printing/ do
+      assert_select '.total-for-estimated-hours span.value', :text => '7.80'
+    end
+    assert_select 'tr.group', :text => /Recipes/ do
+      assert_select '.total-for-estimated-hours span.value', :text => '1.10'
+    end
+    assert_select 'tr.group', :text => /blank/ do
+      assert_select '.total-for-estimated-hours span.value', :text => '4.60'
+    end
+  end
+
   def test_index_with_int_custom_field_total
     field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true)
     CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2')
index b69bec7409463d208677af0626636235c8ad648f..679fc772db439694abfe7ee629231b57f32fda18 100644 (file)
@@ -1183,6 +1183,19 @@ class QueryTest < ActiveSupport::TestCase
     assert_equal 6.6, q.total_for(:estimated_hours)
   end
 
+  def test_total_by_group_for_estimated_hours
+    Issue.delete_all
+    Issue.generate!(:estimated_hours => 5.5, :assigned_to_id => 2)
+    Issue.generate!(:estimated_hours => 1.1, :assigned_to_id => 3)
+    Issue.generate!(:estimated_hours => 3.5)
+
+    q = IssueQuery.new(:group_by => 'assigned_to')
+    assert_equal(
+      {nil => 3.5, User.find(2) => 5.5, User.find(3) => 1.1},
+      q.total_by_group_for(:estimated_hours)
+    )
+  end
+
   def test_total_for_spent_hours
     TimeEntry.delete_all
     TimeEntry.generate!(:hours => 5.5)
@@ -1192,6 +1205,20 @@ class QueryTest < ActiveSupport::TestCase
     assert_equal 6.6, q.total_for(:spent_hours)
   end
 
+  def test_total_by_group_for_spent_hours
+    TimeEntry.delete_all
+    TimeEntry.generate!(:hours => 5.5, :issue_id => 1)
+    TimeEntry.generate!(:hours => 1.1, :issue_id => 2)
+    Issue.where(:id => 1).update_all(:assigned_to_id => 2)
+    Issue.where(:id => 2).update_all(:assigned_to_id => 3)
+
+    q = IssueQuery.new(:group_by => 'assigned_to')
+    assert_equal(
+      {User.find(2) => 5.5, User.find(3) => 1.1},
+      q.total_by_group_for(:spent_hours)
+    )
+  end
+
   def test_total_for_int_custom_field
     field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true)
     CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2')
@@ -1202,6 +1229,20 @@ class QueryTest < ActiveSupport::TestCase
     assert_equal 9, q.total_for("cf_#{field.id}")
   end
 
+  def test_total_by_group_for_int_custom_field
+    field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true)
+    CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2')
+    CustomValue.create!(:customized => Issue.find(2), :custom_field => field, :value => '7')
+    Issue.where(:id => 1).update_all(:assigned_to_id => 2)
+    Issue.where(:id => 2).update_all(:assigned_to_id => 3)
+
+    q = IssueQuery.new(:group_by => 'assigned_to')
+    assert_equal(
+      {User.find(2) => 2, User.find(3) => 7},
+      q.total_by_group_for("cf_#{field.id}")
+    )
+  end
+
   def test_total_for_float_custom_field
     field = IssueCustomField.generate!(:field_format => 'float', :is_for_all => true)
     CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2.3')