##// END OF EJS Templates
Adds options to display totals on the issue list (#1561)....
Jean-Philippe Lang -
r14260:446f70f58432
parent child
Show More
@@ -84,6 +84,14 module QueriesHelper
84 tags
84 tags
85 end
85 end
86
86
87 def available_totalable_columns_tags(query)
88 tags = ''.html_safe
89 query.available_totalable_columns.each do |column|
90 tags << content_tag('label', check_box_tag('t[]', column.name.to_s, query.totalable_columns.include?(column), :id => nil) + " #{column.caption}", :class => 'inline')
91 end
92 tags
93 end
94
87 def query_available_inline_columns_options(query)
95 def query_available_inline_columns_options(query)
88 (query.available_inline_columns - query.columns).reject(&:frozen?).collect {|column| [column.caption, column.name]}
96 (query.available_inline_columns - query.columns).reject(&:frozen?).collect {|column| [column.caption, column.name]}
89 end
97 end
@@ -97,6 +105,16 module QueriesHelper
97 render :partial => 'queries/columns', :locals => {:query => query, :tag_name => tag_name}
105 render :partial => 'queries/columns', :locals => {:query => query, :tag_name => tag_name}
98 end
106 end
99
107
108 def render_query_totals(query)
109 return unless query.totalable_columns.present?
110 totals = query.totalable_columns.map do |column|
111 label = content_tag('span', "#{column.caption}:")
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
115 content_tag('p', totals.join(" ").html_safe, :class => "query-totals")
116 end
117
100 def column_header(column)
118 def column_header(column)
101 column.sortable ? sort_header_tag(column.name.to_s, :caption => column.caption,
119 column.sortable ? sort_header_tag(column.name.to_s, :caption => column.caption,
102 :default_order => column.default_order) :
120 :default_order => column.default_order) :
@@ -194,12 +212,12 module QueriesHelper
194 @query = IssueQuery.new(:name => "_")
212 @query = IssueQuery.new(:name => "_")
195 @query.project = @project
213 @query.project = @project
196 @query.build_from_params(params)
214 @query.build_from_params(params)
197 session[:query] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names}
215 session[:query] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names, :totalable_names => @query.totalable_names}
198 else
216 else
199 # retrieve from session
217 # retrieve from session
200 @query = nil
218 @query = nil
201 @query = IssueQuery.find_by_id(session[:query][:id]) if session[:query][:id]
219 @query = IssueQuery.find_by_id(session[:query][:id]) if session[:query][:id]
202 @query ||= IssueQuery.new(:name => "_", :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names])
220 @query ||= IssueQuery.new(:name => "_", :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names], :totalable_names => session[:query][:totalable_names])
203 @query.project = @project
221 @query.project = @project
204 end
222 end
205 end
223 end
@@ -210,7 +228,7 module QueriesHelper
210 @query = IssueQuery.find_by_id(session[:query][:id])
228 @query = IssueQuery.find_by_id(session[:query][:id])
211 return unless @query
229 return unless @query
212 else
230 else
213 @query = IssueQuery.new(:name => "_", :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names])
231 @query = IssueQuery.new(:name => "_", :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names], :totalable_names => session[:query][:totalable_names])
214 end
232 end
215 if session[:query].has_key?(:project_id)
233 if session[:query].has_key?(:project_id)
216 @query.project_id = session[:query][:project_id]
234 @query.project_id = session[:query][:project_id]
@@ -34,7 +34,7 class IssueQuery < Query
34 QueryColumn.new(:fixed_version, :sortable => lambda {Version.fields_for_order_statement}, :groupable => true),
34 QueryColumn.new(:fixed_version, :sortable => lambda {Version.fields_for_order_statement}, :groupable => true),
35 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
35 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
36 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
36 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
37 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
37 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours", :totalable => true),
38 QueryColumn.new(:total_estimated_hours,
38 QueryColumn.new(:total_estimated_hours,
39 :sortable => "COALESCE((SELECT SUM(estimated_hours) FROM #{Issue.table_name} subtasks" +
39 :sortable => "COALESCE((SELECT SUM(estimated_hours) FROM #{Issue.table_name} subtasks" +
40 " WHERE subtasks.root_id = #{Issue.table_name}.root_id AND subtasks.lft >= #{Issue.table_name}.lft AND subtasks.rgt <= #{Issue.table_name}.rgt), 0)",
40 " WHERE subtasks.root_id = #{Issue.table_name}.root_id AND subtasks.lft >= #{Issue.table_name}.lft AND subtasks.rgt <= #{Issue.table_name}.rgt), 0)",
@@ -268,7 +268,8 class IssueQuery < Query
268 @available_columns.insert index, QueryColumn.new(:spent_hours,
268 @available_columns.insert index, QueryColumn.new(:spent_hours,
269 :sortable => "COALESCE((SELECT SUM(hours) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id), 0)",
269 :sortable => "COALESCE((SELECT SUM(hours) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id), 0)",
270 :default_order => 'desc',
270 :default_order => 'desc',
271 :caption => :label_spent_time
271 :caption => :label_spent_time,
272 :totalable => true
272 )
273 )
273 @available_columns.insert index+1, QueryColumn.new(:total_spent_hours,
274 @available_columns.insert index+1, QueryColumn.new(:total_spent_hours,
274 :sortable => "COALESCE((SELECT SUM(hours) FROM #{TimeEntry.table_name} JOIN #{Issue.table_name} subtasks ON subtasks.id = #{TimeEntry.table_name}.issue_id" +
275 :sortable => "COALESCE((SELECT SUM(hours) FROM #{TimeEntry.table_name} JOIN #{Issue.table_name} subtasks ON subtasks.id = #{TimeEntry.table_name}.issue_id" +
@@ -299,13 +300,44 class IssueQuery < Query
299 end
300 end
300 end
301 end
301
302
303 def base_scope
304 Issue.visible.joins(:status, :project).where(statement)
305 end
306 private :base_scope
307
302 # Returns the issue count
308 # Returns the issue count
303 def issue_count
309 def issue_count
304 Issue.visible.joins(:status, :project).where(statement).count
310 base_scope.count
305 rescue ::ActiveRecord::StatementInvalid => e
311 rescue ::ActiveRecord::StatementInvalid => e
306 raise StatementInvalid.new(e.message)
312 raise StatementInvalid.new(e.message)
307 end
313 end
308
314
315 # Returns sum of all the issue's estimated_hours
316 def total_for_estimated_hours
317 base_scope.sum(:estimated_hours)
318 end
319
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")
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
336
337 def total_for_int_custom_field(custom_field)
338 total_for_custom_field(custom_field).to_i
339 end
340
309 # Returns the issue count by group or nil if query is not grouped
341 # Returns the issue count by group or nil if query is not grouped
310 def issue_count_by_group
342 def issue_count_by_group
311 r = nil
343 r = nil
@@ -16,7 +16,7
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class QueryColumn
18 class QueryColumn
19 attr_accessor :name, :sortable, :groupable, :default_order
19 attr_accessor :name, :sortable, :groupable, :totalable, :default_order
20 include Redmine::I18n
20 include Redmine::I18n
21
21
22 def initialize(name, options={})
22 def initialize(name, options={})
@@ -26,6 +26,7 class QueryColumn
26 if groupable == true
26 if groupable == true
27 self.groupable = name.to_s
27 self.groupable = name.to_s
28 end
28 end
29 self.totalable = options[:totalable] || false
29 self.default_order = options[:default_order]
30 self.default_order = options[:default_order]
30 @inline = options.key?(:inline) ? options[:inline] : true
31 @inline = options.key?(:inline) ? options[:inline] : true
31 @caption_key = options[:caption] || "field_#{name}".to_sym
32 @caption_key = options[:caption] || "field_#{name}".to_sym
@@ -79,6 +80,7 class QueryCustomFieldColumn < QueryColumn
79 self.name = "cf_#{custom_field.id}".to_sym
80 self.name = "cf_#{custom_field.id}".to_sym
80 self.sortable = custom_field.order_statement || false
81 self.sortable = custom_field.order_statement || false
81 self.groupable = custom_field.group_statement || false
82 self.groupable = custom_field.group_statement || false
83 self.totalable = ['int', 'float'].include?(custom_field.field_format)
82 @inline = true
84 @inline = true
83 @cf = custom_field
85 @cf = custom_field
84 end
86 end
@@ -246,6 +248,7 class Query < ActiveRecord::Base
246 end
248 end
247 self.group_by = params[:group_by] || (params[:query] && params[:query][:group_by])
249 self.group_by = params[:group_by] || (params[:query] && params[:query][:group_by])
248 self.column_names = params[:c] || (params[:query] && params[:query][:column_names])
250 self.column_names = params[:c] || (params[:query] && params[:query][:column_names])
251 self.totalable_names = params[:t] || (params[:query] && params[:query][:totalable_names])
249 self
252 self
250 end
253 end
251
254
@@ -454,6 +457,10 class Query < ActiveRecord::Base
454 available_columns.reject(&:inline?)
457 available_columns.reject(&:inline?)
455 end
458 end
456
459
460 def available_totalable_columns
461 available_columns.select(&:totalable)
462 end
463
457 def default_columns_names
464 def default_columns_names
458 []
465 []
459 end
466 end
@@ -482,6 +489,22 class Query < ActiveRecord::Base
482 column_names.nil? || column_names.empty?
489 column_names.nil? || column_names.empty?
483 end
490 end
484
491
492 def totalable_columns
493 names = totalable_names
494 available_totalable_columns.select {|column| names.include?(column.name)}
495 end
496
497 def totalable_names=(names)
498 if names
499 names = names.select(&:present?).map {|n| n.is_a?(Symbol) ? n : n.to_sym}
500 end
501 options[:totalable_names] = names
502 end
503
504 def totalable_names
505 options[:totalable_names] || Setting.issue_list_default_totals.map(&:to_sym) || []
506 end
507
485 def sort_criteria=(arg)
508 def sort_criteria=(arg)
486 c = []
509 c = []
487 if arg.is_a?(Hash)
510 if arg.is_a?(Hash)
@@ -607,6 +630,22 class Query < ActiveRecord::Base
607 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
630 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
608 end
631 end
609
632
633 # Returns the sum of values for the given column
634 def total_for(column)
635 unless column.is_a?(QueryColumn)
636 column = column.to_sym
637 column = available_totalable_columns.detect {|c| c.name == column}
638 end
639 if column.is_a?(QueryCustomFieldColumn)
640 custom_field = column.custom_field
641 send "total_for_#{custom_field.field_format}_custom_field", custom_field
642 else
643 send "total_for_#{column.name}"
644 end
645 rescue ::ActiveRecord::StatementInvalid => e
646 raise StatementInvalid.new(e.message)
647 end
648
610 private
649 private
611
650
612 def sql_for_custom_field(field, operator, value, custom_field_id)
651 def sql_for_custom_field(field, operator, value, custom_field_id)
@@ -39,6 +39,10
39 <td><%= l(:button_show) %></td>
39 <td><%= l(:button_show) %></td>
40 <td><%= available_block_columns_tags(@query) %></td>
40 <td><%= available_block_columns_tags(@query) %></td>
41 </tr>
41 </tr>
42 <tr>
43 <td><%= l(:label_total_plural) %></td>
44 <td><%= available_totalable_columns_tags(@query) %></td>
45 </tr>
42 </table>
46 </table>
43 </div>
47 </div>
44 </fieldset>
48 </fieldset>
@@ -60,6 +64,7
60 <% if @issues.empty? %>
64 <% if @issues.empty? %>
61 <p class="nodata"><%= l(:label_no_data) %></p>
65 <p class="nodata"><%= l(:label_no_data) %></p>
62 <% else %>
66 <% else %>
67 <%= render_query_totals(@query) %>
63 <%= render :partial => 'issues/list', :locals => {:issues => @issues, :query => @query} %>
68 <%= render :partial => 'issues/list', :locals => {:issues => @issues, :query => @query} %>
64 <p class="pagination"><%= pagination_links_full @issue_pages, @issue_count %></p>
69 <p class="pagination"><%= pagination_links_full @issue_pages, @issue_count %></p>
65 <% end %>
70 <% end %>
@@ -33,6 +33,9
33
33
34 <p><label><%= l(:button_show) %></label>
34 <p><label><%= l(:button_show) %></label>
35 <%= available_block_columns_tags(@query) %></p>
35 <%= available_block_columns_tags(@query) %></p>
36
37 <p><label><%= l(:label_total_plural) %></label>
38 <%= available_totalable_columns_tags(@query) %></p>
36 </fieldset>
39 </fieldset>
37 <% else %>
40 <% else %>
38 <fieldset><legend><%= l(:label_options) %></legend>
41 <fieldset><legend><%= l(:label_options) %></legend>
@@ -38,6 +38,11
38 <%= render_query_columns_selection(
38 <%= render_query_columns_selection(
39 IssueQuery.new(:column_names => Setting.issue_list_default_columns),
39 IssueQuery.new(:column_names => Setting.issue_list_default_columns),
40 :name => 'settings[issue_list_default_columns]') %>
40 :name => 'settings[issue_list_default_columns]') %>
41
42 <p><%= setting_multiselect :issue_list_default_totals,
43 IssueQuery.new(:totalable_names => Setting.issue_list_default_totals).available_totalable_columns.map {|c| [c.caption, c.name.to_s]},
44 :inline => true,
45 :label => :label_total_plural %></p>
41 </fieldset>
46 </fieldset>
42
47
43 <%= submit_tag l(:button_save) %>
48 <%= submit_tag l(:button_save) %>
@@ -646,6 +646,7 en:
646 one: 1 issue
646 one: 1 issue
647 other: "%{count} issues"
647 other: "%{count} issues"
648 label_total: Total
648 label_total: Total
649 label_total_plural: Totals
649 label_total_time: Total time
650 label_total_time: Total time
650 label_permissions: Permissions
651 label_permissions: Permissions
651 label_current_status: Current status
652 label_current_status: Current status
@@ -666,6 +666,7 fr:
666 one: 1 demande
666 one: 1 demande
667 other: "%{count} demandes"
667 other: "%{count} demandes"
668 label_total: Total
668 label_total: Total
669 label_total_plural: Totaux
669 label_total_time: Temps total
670 label_total_time: Temps total
670 label_permissions: Permissions
671 label_permissions: Permissions
671 label_current_status: Statut actuel
672 label_current_status: Statut actuel
@@ -180,6 +180,9 issue_list_default_columns:
180 - subject
180 - subject
181 - assigned_to
181 - assigned_to
182 - updated_on
182 - updated_on
183 issue_list_default_totals:
184 serialized: true
185 default: []
183 display_subprojects_issues:
186 display_subprojects_issues:
184 default: 1
187 default: 1
185 issue_done_ratio:
188 issue_done_ratio:
@@ -271,6 +271,9 table.query-columns td.buttons {
271 text-align: center;
271 text-align: center;
272 }
272 }
273 table.query-columns td.buttons input[type=button] {width:35px;}
273 table.query-columns td.buttons input[type=button] {width:35px;}
274 .query-totals {text-align:right; margin-top:-2.3em;}
275 .query-totals>span {margin-left:0.6em;}
276 .query-totals .value {font-weight:bold;}
274
277
275 td.center {text-align:center;}
278 td.center {text-align:center;}
276
279
@@ -945,6 +945,38 class IssuesControllerTest < ActionController::TestCase
945 assert_select 'td.parent a[title=?]', parent.subject
945 assert_select 'td.parent a[title=?]', parent.subject
946 end
946 end
947
947
948 def test_index_with_estimated_hours_total
949 Issue.delete_all
950 Issue.generate!(:estimated_hours => 5.5)
951 Issue.generate!(:estimated_hours => 1.1)
952
953 get :index, :t => %w(estimated_hours)
954 assert_response :success
955 assert_select '.query-totals'
956 assert_select '.total-for-estimated-hours span.value', :text => '6.6'
957 assert_select 'input[type=checkbox][name=?][value=estimated_hours][checked=checked]', 't[]'
958 end
959
960 def test_index_with_int_custom_field_total
961 field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true)
962 CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2')
963 CustomValue.create!(:customized => Issue.find(2), :custom_field => field, :value => '7')
964
965 get :index, :t => ["cf_#{field.id}"]
966 assert_response :success
967 assert_select '.query-totals'
968 assert_select ".total-for-cf-#{field.id} span.value", :text => '9'
969 end
970
971 def test_index_totals_should_default_to_settings
972 with_settings :issue_list_default_totals => ['estimated_hours'] do
973 get :index
974 assert_response :success
975 assert_select '.total-for-estimated-hours span.value'
976 assert_select '.query-totals>span', 1
977 end
978 end
979
948 def test_index_send_html_if_query_is_invalid
980 def test_index_send_html_if_query_is_invalid
949 get :index, :f => ['start_date'], :op => {:start_date => '='}
981 get :index, :f => ['start_date'], :op => {:start_date => '='}
950 assert_equal 'text/html', @response.content_type
982 assert_equal 'text/html', @response.content_type
@@ -1136,6 +1136,82 class QueryTest < ActiveSupport::TestCase
1136 assert_equal values.sort, values
1136 assert_equal values.sort, values
1137 end
1137 end
1138
1138
1139 def test_set_totalable_names
1140 q = IssueQuery.new
1141 q.totalable_names = ['estimated_hours', :spent_hours, '']
1142 assert_equal [:estimated_hours, :spent_hours], q.totalable_columns.map(&:name)
1143 end
1144
1145 def test_totalable_columns_should_default_to_settings
1146 with_settings :issue_list_default_totals => ['estimated_hours'] do
1147 q = IssueQuery.new
1148 assert_equal [:estimated_hours], q.totalable_columns.map(&:name)
1149 end
1150 end
1151
1152 def test_available_totalable_columns_should_include_estimated_hours
1153 q = IssueQuery.new
1154 assert_include :estimated_hours, q.available_totalable_columns.map(&:name)
1155 end
1156
1157 def test_available_totalable_columns_should_include_spent_hours
1158 User.current = User.find(1)
1159
1160 q = IssueQuery.new
1161 assert_include :spent_hours, q.available_totalable_columns.map(&:name)
1162 end
1163
1164 def test_available_totalable_columns_should_include_int_custom_field
1165 field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true)
1166 q = IssueQuery.new
1167 assert_include "cf_#{field.id}".to_sym, q.available_totalable_columns.map(&:name)
1168 end
1169
1170 def test_available_totalable_columns_should_include_float_custom_field
1171 field = IssueCustomField.generate!(:field_format => 'float', :is_for_all => true)
1172 q = IssueQuery.new
1173 assert_include "cf_#{field.id}".to_sym, q.available_totalable_columns.map(&:name)
1174 end
1175
1176 def test_total_for_estimated_hours
1177 Issue.delete_all
1178 Issue.generate!(:estimated_hours => 5.5)
1179 Issue.generate!(:estimated_hours => 1.1)
1180 Issue.generate!
1181
1182 q = IssueQuery.new
1183 assert_equal 6.6, q.total_for(:estimated_hours)
1184 end
1185
1186 def test_total_for_spent_hours
1187 TimeEntry.delete_all
1188 TimeEntry.generate!(:hours => 5.5)
1189 TimeEntry.generate!(:hours => 1.1)
1190
1191 q = IssueQuery.new
1192 assert_equal 6.6, q.total_for(:spent_hours)
1193 end
1194
1195 def test_total_for_int_custom_field
1196 field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true)
1197 CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2')
1198 CustomValue.create!(:customized => Issue.find(2), :custom_field => field, :value => '7')
1199 CustomValue.create!(:customized => Issue.find(3), :custom_field => field, :value => '')
1200
1201 q = IssueQuery.new
1202 assert_equal 9, q.total_for("cf_#{field.id}")
1203 end
1204
1205 def test_total_for_float_custom_field
1206 field = IssueCustomField.generate!(:field_format => 'float', :is_for_all => true)
1207 CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2.3')
1208 CustomValue.create!(:customized => Issue.find(2), :custom_field => field, :value => '7')
1209 CustomValue.create!(:customized => Issue.find(3), :custom_field => field, :value => '')
1210
1211 q = IssueQuery.new
1212 assert_equal 9.3, q.total_for("cf_#{field.id}")
1213 end
1214
1139 def test_invalid_query_should_raise_query_statement_invalid_error
1215 def test_invalid_query_should_raise_query_statement_invalid_error
1140 q = IssueQuery.new
1216 q = IssueQuery.new
1141 assert_raise Query::StatementInvalid do
1217 assert_raise Query::StatementInvalid do
General Comments 0
You need to be logged in to leave comments. Login now