@@ -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);"> </span> |
|
23 | <span class="expander" onclick="toggleRowGroup(this);"> </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 |
|
|
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