##// END OF EJS Templates
Display totals for each group on grouped queries (#1561)....
Jean-Philippe Lang -
r14283:498a429a41bc
parent child
Show More
@@ -34,6 +34,10 module IssuesHelper
34
34
35 def grouped_issue_list(issues, query, issue_count_by_group, &block)
35 def grouped_issue_list(issues, query, issue_count_by_group, &block)
36 previous_group, first = false, true
36 previous_group, first = false, true
37 totals_by_group = query.totalable_columns.inject({}) do |h, column|
38 h[column] = query.total_by_group_for(column)
39 h
40 end
37 issue_list(issues) do |issue, level|
41 issue_list(issues) do |issue, level|
38 group_name = group_count = nil
42 group_name = group_count = nil
39 if query.grouped? && ((group = query.group_by_column.value(issue)) != previous_group || first)
43 if query.grouped? && ((group = query.group_by_column.value(issue)) != previous_group || first)
@@ -44,8 +48,9 module IssuesHelper
44 end
48 end
45 group_name ||= ""
49 group_name ||= ""
46 group_count = issue_count_by_group[group]
50 group_count = issue_count_by_group[group]
51 group_totals = totals_by_group.map {|column, t| total_tag(column, t[group] || 0)}.join(" ").html_safe
47 end
52 end
48 yield issue, level, group_name, group_count
53 yield issue, level, group_name, group_count, group_totals
49 previous_group, first = group, false
54 previous_group, first = group, false
50 end
55 end
51 end
56 end
@@ -108,13 +108,17 module QueriesHelper
108 def render_query_totals(query)
108 def render_query_totals(query)
109 return unless query.totalable_columns.present?
109 return unless query.totalable_columns.present?
110 totals = query.totalable_columns.map do |column|
110 totals = query.totalable_columns.map do |column|
111 label = content_tag('span', "#{column.caption}:")
111 total_tag(column, query.total_for(column))
112 value = content_tag('span', " #{query.total_for(column)}", :class => 'value')
113 content_tag('span', label + " " + value, :class => "total-for-#{column.name.to_s.dasherize}")
114 end
112 end
115 content_tag('p', totals.join(" ").html_safe, :class => "query-totals")
113 content_tag('p', totals.join(" ").html_safe, :class => "query-totals")
116 end
114 end
117
115
116 def total_tag(column, value)
117 label = content_tag('span', "#{column.caption}:")
118 value = content_tag('span', format_object(value), :class => 'value')
119 content_tag('span', label + " " + value, :class => "total-for-#{column.name.to_s.dasherize}")
120 end
121
118 def column_header(column)
122 def column_header(column)
119 column.sortable ? sort_header_tag(column.name.to_s, :caption => column.caption,
123 column.sortable ? sort_header_tag(column.name.to_s, :caption => column.caption,
120 :default_order => column.default_order) :
124 :default_order => column.default_order) :
@@ -303,7 +303,6 class IssueQuery < Query
303 def base_scope
303 def base_scope
304 Issue.visible.joins(:status, :project).where(statement)
304 Issue.visible.joins(:status, :project).where(statement)
305 end
305 end
306 private :base_scope
307
306
308 # Returns the issue count
307 # Returns the issue count
309 def issue_count
308 def issue_count
@@ -312,55 +311,21 class IssueQuery < Query
312 raise StatementInvalid.new(e.message)
311 raise StatementInvalid.new(e.message)
313 end
312 end
314
313
315 # Returns sum of all the issue's estimated_hours
314 # Returns the issue count by group or nil if query is not grouped
316 def total_for_estimated_hours
315 def issue_count_by_group
317 base_scope.sum(:estimated_hours).to_f.round(2)
316 grouped_query do |scope|
318 end
317 scope.count
319
318 end
320 # Returns sum of all the issue's time entries hours
321 def total_for_spent_hours
322 base_scope.joins(:time_entries).sum("#{TimeEntry.table_name}.hours").to_f.round(2)
323 end
324
325 def total_for_custom_field(custom_field)
326 base_scope.joins(:custom_values).
327 where(:custom_values => {:custom_field_id => custom_field.id}).
328 where.not(:custom_values => {:value => ''}).
329 sum("CAST(#{CustomValue.table_name}.value AS decimal(30,3))")
330 end
331 private :total_for_custom_field
332
333 def total_for_float_custom_field(custom_field)
334 total_for_custom_field(custom_field).to_f
335 end
319 end
336
320
337 def total_for_int_custom_field(custom_field)
321 # Returns sum of all the issue's estimated_hours
338 total_for_custom_field(custom_field).to_i
322 def total_for_estimated_hours(scope)
323 scope.sum(:estimated_hours)
339 end
324 end
340
325
341 # Returns the issue count by group or nil if query is not grouped
326 # Returns sum of all the issue's time entries hours
342 def issue_count_by_group
327 def total_for_spent_hours(scope)
343 r = nil
328 scope.joins(:time_entries).sum("#{TimeEntry.table_name}.hours")
344 if grouped?
345 begin
346 # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
347 r = Issue.visible.
348 joins(:status, :project).
349 where(statement).
350 joins(joins_for_order_statement(group_by_statement)).
351 group(group_by_statement).
352 count
353 rescue ActiveRecord::RecordNotFound
354 r = {nil => issue_count}
355 end
356 c = group_by_column
357 if c.is_a?(QueryCustomFieldColumn)
358 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
359 end
360 end
361 r
362 rescue ::ActiveRecord::StatementInvalid => e
363 raise StatementInvalid.new(e.message)
364 end
329 end
365
330
366 # Returns the issues
331 # Returns the issues
@@ -632,21 +632,99 class Query < ActiveRecord::Base
632
632
633 # Returns the sum of values for the given column
633 # Returns the sum of values for the given column
634 def total_for(column)
634 def total_for(column)
635 total_with_scope(column, base_scope)
636 end
637
638 # Returns a hash of the sum of the given column for each group,
639 # or nil if the query is not grouped
640 def total_by_group_for(column)
641 grouped_query do |scope|
642 total_with_scope(column, scope)
643 end
644 end
645
646 def totals
647 totals = totalable_columns.map {|column| [column, total_for(column)]}
648 yield totals if block_given?
649 totals
650 end
651
652 def totals_by_group
653 totals = totalable_columns.map {|column| [column, total_by_group_for(column)]}
654 yield totals if block_given?
655 totals
656 end
657
658 private
659
660 def grouped_query(&block)
661 r = nil
662 if grouped?
663 begin
664 # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
665 r = yield base_group_scope
666 rescue ActiveRecord::RecordNotFound
667 r = {nil => yield(base_scope)}
668 end
669 c = group_by_column
670 if c.is_a?(QueryCustomFieldColumn)
671 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
672 end
673 end
674 r
675 rescue ::ActiveRecord::StatementInvalid => e
676 raise StatementInvalid.new(e.message)
677 end
678
679 def total_with_scope(column, scope)
635 unless column.is_a?(QueryColumn)
680 unless column.is_a?(QueryColumn)
636 column = column.to_sym
681 column = column.to_sym
637 column = available_totalable_columns.detect {|c| c.name == column}
682 column = available_totalable_columns.detect {|c| c.name == column}
638 end
683 end
639 if column.is_a?(QueryCustomFieldColumn)
684 if column.is_a?(QueryCustomFieldColumn)
640 custom_field = column.custom_field
685 custom_field = column.custom_field
641 send "total_for_#{custom_field.field_format}_custom_field", custom_field
686 send "total_for_#{custom_field.field_format}_custom_field", custom_field, scope
642 else
687 else
643 send "total_for_#{column.name}"
688 send "total_for_#{column.name}", scope
644 end
689 end
645 rescue ::ActiveRecord::StatementInvalid => e
690 rescue ::ActiveRecord::StatementInvalid => e
646 raise StatementInvalid.new(e.message)
691 raise StatementInvalid.new(e.message)
647 end
692 end
648
693
649 private
694 def base_scope
695 raise "unimplemented"
696 end
697
698 def base_group_scope
699 base_scope.
700 joins(joins_for_order_statement(group_by_statement)).
701 group(group_by_statement)
702 end
703
704 def total_for_float_custom_field(custom_field, scope)
705 total_for_custom_field(custom_field, scope) {|t| t.to_f.round(2)}
706 end
707
708 def total_for_int_custom_field(custom_field, scope)
709 total_for_custom_field(custom_field, scope) {|t| t.to_i}
710 end
711
712 def total_for_custom_field(custom_field, scope)
713 total = scope.joins(:custom_values).
714 where(:custom_values => {:custom_field_id => custom_field.id}).
715 where.not(:custom_values => {:value => ''}).
716 sum("CAST(#{CustomValue.table_name}.value AS decimal(30,3))")
717
718 if block_given?
719 if total.is_a?(Hash)
720 total.keys.each {|k| total[k] = yield total[k]}
721 else
722 total = yield total
723 end
724 end
725
726 total
727 end
650
728
651 def sql_for_custom_field(field, operator, value, custom_field_id)
729 def sql_for_custom_field(field, operator, value, custom_field_id)
652 db_table = CustomValue.table_name
730 db_table = CustomValue.table_name
@@ -15,13 +15,13
15 </tr>
15 </tr>
16 </thead>
16 </thead>
17 <tbody>
17 <tbody>
18 <% grouped_issue_list(issues, @query, @issue_count_by_group) do |issue, level, group_name, group_count| -%>
18 <% grouped_issue_list(issues, @query, @issue_count_by_group) do |issue, level, group_name, group_count, group_totals| -%>
19 <% if group_name %>
19 <% if group_name %>
20 <% reset_cycle %>
20 <% reset_cycle %>
21 <tr class="group open">
21 <tr class="group open">
22 <td colspan="<%= query.inline_columns.size + 2 %>">
22 <td colspan="<%= query.inline_columns.size + 2 %>">
23 <span class="expander" onclick="toggleRowGroup(this);">&nbsp;</span>
23 <span class="expander" onclick="toggleRowGroup(this);">&nbsp;</span>
24 <%= group_name %> <span class="count"><%= group_count %></span>
24 <span class="name"><%= group_name %></span> <span class="count"><%= group_count %></span> <span class="totals"><%= group_totals %></span>
25 <%= link_to_function("#{l(:button_collapse_all)}/#{l(:button_expand_all)}",
25 <%= link_to_function("#{l(:button_collapse_all)}/#{l(:button_expand_all)}",
26 "toggleAllRowGroups(this)", :class => 'toggle-all') %>
26 "toggleAllRowGroups(this)", :class => 'toggle-all') %>
27 </td>
27 </td>
@@ -231,9 +231,12 table.plugins span.name { font-weight: bold; display: block; margin-bottom: 6px;
231 table.plugins span.description { display: block; font-size: 0.9em; }
231 table.plugins span.description { display: block; font-size: 0.9em; }
232 table.plugins span.url { display: block; font-size: 0.9em; }
232 table.plugins span.url { display: block; font-size: 0.9em; }
233
233
234 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; }
234 tr.group td { padding: 0.8em 0 0.5em 0.3em; border-bottom: 1px solid #ccc; text-align:left; }
235 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;}
235 tr.group span.name {font-weight:bold;}
236 tr.group a.toggle-all { color: #aaa; font-size: 80%; font-weight: normal; display:none;}
236 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;}
237 tr.group span.totals {color: #aaa; font-size: 80%;}
238 tr.group span.totals .value {font-weight:bold; color:#777;}
239 tr.group a.toggle-all { color: #aaa; font-size: 80%; display:none; float:right; margin-right:4px;}
237 tr.group:hover a.toggle-all { display:inline;}
240 tr.group:hover a.toggle-all { display:inline;}
238 a.toggle-all:hover {text-decoration:none;}
241 a.toggle-all:hover {text-decoration:none;}
239
242
@@ -953,10 +953,32 class IssuesControllerTest < ActionController::TestCase
953 get :index, :t => %w(estimated_hours)
953 get :index, :t => %w(estimated_hours)
954 assert_response :success
954 assert_response :success
955 assert_select '.query-totals'
955 assert_select '.query-totals'
956 assert_select '.total-for-estimated-hours span.value', :text => '6.6'
956 assert_select '.total-for-estimated-hours span.value', :text => '6.60'
957 assert_select 'input[type=checkbox][name=?][value=estimated_hours][checked=checked]', 't[]'
957 assert_select 'input[type=checkbox][name=?][value=estimated_hours][checked=checked]', 't[]'
958 end
958 end
959
959
960 def test_index_with_grouped_query_and_estimated_hours_total
961 Issue.delete_all
962 Issue.generate!(:estimated_hours => 5.5, :category_id => 1)
963 Issue.generate!(:estimated_hours => 2.3, :category_id => 1)
964 Issue.generate!(:estimated_hours => 1.1, :category_id => 2)
965 Issue.generate!(:estimated_hours => 4.6)
966
967 get :index, :t => %w(estimated_hours), :group_by => 'category'
968 assert_response :success
969 assert_select '.query-totals'
970 assert_select '.query-totals .total-for-estimated-hours span.value', :text => '13.50'
971 assert_select 'tr.group', :text => /Printing/ do
972 assert_select '.total-for-estimated-hours span.value', :text => '7.80'
973 end
974 assert_select 'tr.group', :text => /Recipes/ do
975 assert_select '.total-for-estimated-hours span.value', :text => '1.10'
976 end
977 assert_select 'tr.group', :text => /blank/ do
978 assert_select '.total-for-estimated-hours span.value', :text => '4.60'
979 end
980 end
981
960 def test_index_with_int_custom_field_total
982 def test_index_with_int_custom_field_total
961 field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true)
983 field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true)
962 CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2')
984 CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2')
@@ -1183,6 +1183,19 class QueryTest < ActiveSupport::TestCase
1183 assert_equal 6.6, q.total_for(:estimated_hours)
1183 assert_equal 6.6, q.total_for(:estimated_hours)
1184 end
1184 end
1185
1185
1186 def test_total_by_group_for_estimated_hours
1187 Issue.delete_all
1188 Issue.generate!(:estimated_hours => 5.5, :assigned_to_id => 2)
1189 Issue.generate!(:estimated_hours => 1.1, :assigned_to_id => 3)
1190 Issue.generate!(:estimated_hours => 3.5)
1191
1192 q = IssueQuery.new(:group_by => 'assigned_to')
1193 assert_equal(
1194 {nil => 3.5, User.find(2) => 5.5, User.find(3) => 1.1},
1195 q.total_by_group_for(:estimated_hours)
1196 )
1197 end
1198
1186 def test_total_for_spent_hours
1199 def test_total_for_spent_hours
1187 TimeEntry.delete_all
1200 TimeEntry.delete_all
1188 TimeEntry.generate!(:hours => 5.5)
1201 TimeEntry.generate!(:hours => 5.5)
@@ -1192,6 +1205,20 class QueryTest < ActiveSupport::TestCase
1192 assert_equal 6.6, q.total_for(:spent_hours)
1205 assert_equal 6.6, q.total_for(:spent_hours)
1193 end
1206 end
1194
1207
1208 def test_total_by_group_for_spent_hours
1209 TimeEntry.delete_all
1210 TimeEntry.generate!(:hours => 5.5, :issue_id => 1)
1211 TimeEntry.generate!(:hours => 1.1, :issue_id => 2)
1212 Issue.where(:id => 1).update_all(:assigned_to_id => 2)
1213 Issue.where(:id => 2).update_all(:assigned_to_id => 3)
1214
1215 q = IssueQuery.new(:group_by => 'assigned_to')
1216 assert_equal(
1217 {User.find(2) => 5.5, User.find(3) => 1.1},
1218 q.total_by_group_for(:spent_hours)
1219 )
1220 end
1221
1195 def test_total_for_int_custom_field
1222 def test_total_for_int_custom_field
1196 field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true)
1223 field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true)
1197 CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2')
1224 CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2')
@@ -1202,6 +1229,20 class QueryTest < ActiveSupport::TestCase
1202 assert_equal 9, q.total_for("cf_#{field.id}")
1229 assert_equal 9, q.total_for("cf_#{field.id}")
1203 end
1230 end
1204
1231
1232 def test_total_by_group_for_int_custom_field
1233 field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true)
1234 CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2')
1235 CustomValue.create!(:customized => Issue.find(2), :custom_field => field, :value => '7')
1236 Issue.where(:id => 1).update_all(:assigned_to_id => 2)
1237 Issue.where(:id => 2).update_all(:assigned_to_id => 3)
1238
1239 q = IssueQuery.new(:group_by => 'assigned_to')
1240 assert_equal(
1241 {User.find(2) => 2, User.find(3) => 7},
1242 q.total_by_group_for("cf_#{field.id}")
1243 )
1244 end
1245
1205 def test_total_for_float_custom_field
1246 def test_total_for_float_custom_field
1206 field = IssueCustomField.generate!(:field_format => 'float', :is_for_all => true)
1247 field = IssueCustomField.generate!(:field_format => 'float', :is_for_all => true)
1207 CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2.3')
1248 CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2.3')
General Comments 0
You need to be logged in to leave comments. Login now