]> source.dussan.org Git - redmine.git/commitdiff
Make time entries groupable (#16843).
authorJean-Philippe Lang <jp_lang@yahoo.fr>
Wed, 13 Jul 2016 19:02:48 +0000 (19:02 +0000)
committerJean-Philippe Lang <jp_lang@yahoo.fr>
Wed, 13 Jul 2016 19:02:48 +0000 (19:02 +0000)
git-svn-id: http://svn.redmine.org/redmine/trunk@15649 e93f8b46-1217-0410-a6f0-8f06a7374b81

14 files changed:
app/controllers/timelog_controller.rb
app/helpers/issues_helper.rb
app/helpers/queries_helper.rb
app/models/issue_query.rb
app/models/query.rb
app/models/time_entry_query.rb
app/views/issues/index.html.erb
app/views/queries/_query_form.html.erb [new file with mode: 0644]
app/views/timelog/_date_range.html.erb
app/views/timelog/_list.html.erb
app/views/timelog/index.html.erb
app/views/timelog/report.html.erb
public/stylesheets/application.css
test/functional/timelog_controller_test.rb

index a63ffae82536b98b2133f96ed7160d2eb9b950a9..30560dcfb59e45d439563fe9971ebcf2782dbd09 100644 (file)
@@ -53,7 +53,6 @@ class TimelogController < ApplicationController
         @entry_count = scope.count
         @entry_pages = Paginator.new @entry_count, per_page_option, params['page']
         @entries = scope.offset(@entry_pages.offset).limit(@entry_pages.per_page).to_a
-        @total_hours = scope.sum(:hours).to_f
 
         render :layout => !request.xhr?
       }
index 8d23d95cdf703f4a4ce39a3bf1718ce4da3617ed..42470d14b8b1dcd72177b520f59d278eb82a859a 100644 (file)
@@ -33,28 +33,13 @@ module IssuesHelper
   end
 
   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)
-        if first || group != previous_group
-          if group.blank? && group != false
-            group_name = "(#{l(:label_blank_value)})"
-          else
-            group_name = format_object(group)
-          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
+    ancestors = []
+    grouped_query_results(issues, query, issue_count_by_group) do |issue, group_name, group_count, group_totals|
+      while (ancestors.any? && !issue.is_descendant_of?(ancestors.last))
+        ancestors.pop
       end
-      yield issue, level, group_name, group_count, group_totals
-      previous_group, first = group, false
+      yield issue, ancestors.size, group_name, group_count, group_totals
+      ancestors << issue unless issue.leaf?
     end
   end
 
index e2e09c7de14c8995a4c5ee42441e0b9ec20f2b38..6bf8b3177a0182fe722febd89f01c92de2f3c81e 100644 (file)
@@ -78,6 +78,11 @@ module QueriesHelper
     query_filters_hidden_tags(query) + query_columns_hidden_tags(query)
   end
 
+  def group_by_column_select_tag(query)
+    options = [[]] + query.groupable_columns.collect {|c| [c.caption, c.name.to_s]}
+    select_tag('group_by', options_for_select(options, @query.group_by))
+  end
+
   def available_block_columns_tags(query)
     tags = ''.html_safe
     query.available_block_columns.each do |column|
@@ -108,6 +113,32 @@ module QueriesHelper
     render :partial => 'queries/columns', :locals => {:query => query, :tag_name => tag_name}
   end
 
+  def grouped_query_results(items, query, item_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
+    items.each do |item|
+      group_name = group_count = nil
+      if query.grouped?
+        group = query.group_by_column.value(item)
+        if first || group != previous_group
+          if group.blank? && group != false
+            group_name = "(#{l(:label_blank_value)})"
+          else
+            group_name = format_object(group)
+          end
+          group_name ||= ""
+          group_count = item_count_by_group ? item_count_by_group[group] : nil
+          group_totals = totals_by_group.map {|column, t| total_tag(column, t[group] || 0)}.join(" ").html_safe
+        end
+      end
+      yield item, group_name, group_count, group_totals
+      previous_group, first = group, false
+    end
+  end
+
   def render_query_totals(query)
     return unless query.totalable_columns.present?
     totals = query.totalable_columns.map do |column|
index e13c46a2fc67c0dc1deb5249950c83e9934eceee..0846d7096da1af1b9ee9408ec741373328619b08 100644 (file)
@@ -249,6 +249,10 @@ class IssueQuery < Query
     end
   end
 
+  def default_totalable_names
+    Setting.issue_list_default_totals.map(&:to_sym)
+  end
+
   def base_scope
     Issue.visible.joins(:status, :project).where(statement)
   end
index 5e8e0cd90ac0fbbc11867848b2ba1fcc8c3defbf..9a1dc0d5a6189f85fd115041f54f4eda3d9daeb4 100644 (file)
@@ -76,11 +76,11 @@ end
 
 class QueryCustomFieldColumn < QueryColumn
 
-  def initialize(custom_field)
+  def initialize(custom_field, options={})
     self.name = "cf_#{custom_field.id}".to_sym
     self.sortable = custom_field.order_statement || false
     self.groupable = custom_field.group_statement || false
-    self.totalable = custom_field.totalable?
+    self.totalable = options.key?(:totalable) ? !!options[:totalable] : custom_field.totalable?
     @inline = true
     @cf = custom_field
   end
@@ -120,8 +120,8 @@ end
 
 class QueryAssociationCustomFieldColumn < QueryCustomFieldColumn
 
-  def initialize(association, custom_field)
-    super(custom_field)
+  def initialize(association, custom_field, options={})
+    super(custom_field, options)
     self.name = "#{association}.cf_#{custom_field.id}".to_sym
     # TODO: support sorting/grouping by association custom field
     self.sortable = false
@@ -546,6 +546,10 @@ class Query < ActiveRecord::Base
     []
   end
 
+  def default_totalable_names
+    []
+  end
+
   def column_names=(names)
     if names
       names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
@@ -583,7 +587,7 @@ class Query < ActiveRecord::Base
   end
 
   def totalable_names
-    options[:totalable_names] || Setting.issue_list_default_totals.map(&:to_sym) || []
+    options[:totalable_names] || default_totalable_names || []
   end
 
   def sort_criteria=(arg)
index dde34264ac5fbb04934ffdade8d2e66b3c5d9482..c667fb65184a61ef391eb82831cb8f1e69ea2791 100644 (file)
@@ -28,7 +28,7 @@ class TimeEntryQuery < Query
     QueryColumn.new(:activity, :sortable => "#{TimeEntryActivity.table_name}.position", :groupable => true),
     QueryColumn.new(:issue, :sortable => "#{Issue.table_name}.id"),
     QueryColumn.new(:comments),
-    QueryColumn.new(:hours, :sortable => "#{TimeEntry.table_name}.hours"),
+    QueryColumn.new(:hours, :sortable => "#{TimeEntry.table_name}.hours", :totalable => true),
   ]
 
   def initialize(attributes=nil, *args)
@@ -105,7 +105,7 @@ class TimeEntryQuery < Query
     @available_columns += TimeEntryCustomField.visible.
                             map {|cf| QueryCustomFieldColumn.new(cf) }
     @available_columns += IssueCustomField.visible.
-                            map {|cf| QueryAssociationCustomFieldColumn.new(:issue, cf) }
+                            map {|cf| QueryAssociationCustomFieldColumn.new(:issue, cf, :totalable => false) }
     @available_columns
   end
 
@@ -113,6 +113,14 @@ class TimeEntryQuery < Query
     @default_columns_names ||= [:project, :spent_on, :user, :activity, :issue, :comments, :hours]
   end
 
+  def default_totalable_names
+    [:hours]
+  end
+
+  def base_scope
+    TimeEntry.visible.where(statement)
+  end
+
   def results_scope(options={})
     order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?)
 
@@ -123,6 +131,11 @@ class TimeEntryQuery < Query
       includes(:activity).
       references(:activity)
   end
+
+  # Returns sum of all the spent hours
+  def total_for_hours(scope)
+    map_total(scope.sum(:hours)) {|t| t.to_f.round(2)}
+  end
  
   def sql_for_issue_id_field(field, operator, value)
     case operator
index d120ce71e59dde05f2f50fdc9fedbe4fc4c55090..d0fd943f0f1fe1a9328034979c2acfc2d79a17db 100644 (file)
@@ -7,65 +7,10 @@
 <h2><%= @query.new_record? ? l(:label_issue_plural) : @query.name %></h2>
 <% html_title(@query.new_record? ? l(:label_issue_plural) : @query.name) %>
 
-<%= form_tag({ :controller => 'issues', :action => 'index', :project_id => @project },
-            :method => :get, :id => 'query_form') do %>
-  <div id="query_form_with_buttons" class="hide-when-print">
-    <%= hidden_field_tag 'set_filter', '1' %>
-    <div id="query_form_content">
-    <fieldset id="filters" class="collapsible <%= @query.new_record? ? "" : "collapsed" %>">
-      <legend onclick="toggleFieldset(this);"><%= l(:label_filter_plural) %></legend>
-      <div style="<%= @query.new_record? ? "" : "display: none;" %>">
-        <%= render :partial => 'queries/filters', :locals => {:query => @query} %>
-      </div>
-    </fieldset>
-    <fieldset id="options" class="collapsible collapsed">
-      <legend onclick="toggleFieldset(this);"><%= l(:label_options) %></legend>
-      <div style="display: none;">
-        <table>
-          <tr>
-            <td class="field"><%= l(:field_column_names) %></td>
-            <td><%= render_query_columns_selection(@query) %></td>
-          </tr>
-          <tr>
-            <td class="field"><label for='group_by'><%= l(:field_group_by) %></label></td>
-            <td><%= select_tag('group_by',
-                               options_for_select(
-                                 [[]] + @query.groupable_columns.collect {|c| [c.caption, c.name.to_s]},
-                                 @query.group_by)
-                       ) %></td>
-          </tr>
-          <tr>
-            <td class="field"><%= l(:button_show) %></td>
-            <td><%= available_block_columns_tags(@query) %></td>
-          </tr>
-          <tr>
-            <td><%= l(:label_total_plural) %></td>
-            <td><%= available_totalable_columns_tags(@query) %></td>
-          </tr>
-        </table>
-      </div>
-    </fieldset>
-    </div>
-    <p class="buttons">
-    <%= link_to_function l(:button_apply), '$("#query_form").submit()', :class => 'icon icon-checked' %>
-    <%= link_to l(:button_clear), { :set_filter => 1, :project_id => @project }, :class => 'icon icon-reload'  %>
-    <% if @query.new_record? %>
-      <% if User.current.allowed_to?(:save_queries, @project, :global => true) %>
-        <%= link_to_function l(:button_save),
-                             "$('#query_form').attr('action', '#{ @project ? new_project_query_path(@project) : new_query_path }').submit()",
-                             :class => 'icon icon-save' %>
-      <% end %>
-    <% else %>
-      <% if @query.editable_by?(User.current) %>
-        <%= link_to l(:button_edit), edit_query_path(@query), :class => 'icon icon-edit' %>
-        <%= delete_link query_path(@query) %>
-      <% end %>
-    <% end %>
-    </p>
-  </div>
+<%= form_tag(_project_issues_path(@project), :method => :get, :id => 'query_form') do %>
+  <%= render :partial => 'queries/query_form' %>
 <% end %>
 
-<%= error_messages_for 'query' %>
 <% if @query.valid? %>
 <% if @issues.empty? %>
 <p class="nodata"><%= l(:label_no_data) %></p>
diff --git a/app/views/queries/_query_form.html.erb b/app/views/queries/_query_form.html.erb
new file mode 100644 (file)
index 0000000..bf0b0c2
--- /dev/null
@@ -0,0 +1,62 @@
+<%= hidden_field_tag 'set_filter', '1' %>
+<%= hidden_field_tag 'type', @query.type, :disabled => true, :id => 'query_type' %>
+
+<div id="query_form_with_buttons" class="hide-when-print">
+<div id="query_form_content">
+  <fieldset id="filters" class="collapsible <%= @query.new_record? ? "" : "collapsed" %>">
+    <legend onclick="toggleFieldset(this);"><%= l(:label_filter_plural) %></legend>
+    <div style="<%= @query.new_record? ? "" : "display: none;" %>">
+      <%= render :partial => 'queries/filters', :locals => {:query => @query} %>
+    </div>
+  </fieldset>
+
+  <fieldset id="options" class="collapsible collapsed">
+    <legend onclick="toggleFieldset(this);"><%= l(:label_options) %></legend>
+    <div style="display: none;">
+      <table>
+        <tr>
+          <td class="field"><%= l(:field_column_names) %></td>
+          <td><%= render_query_columns_selection(@query) %></td>
+        </tr>
+        <% if @query.groupable_columns.any? %>
+        <tr>
+          <td class="field"><label for='group_by'><%= l(:field_group_by) %></label></td>
+          <td><%= group_by_column_select_tag(@query) %></td>
+        </tr>
+        <% end %>
+        <% if @query.available_block_columns.any? %>
+        <tr>
+          <td class="field"><%= l(:button_show) %></td>
+          <td><%= available_block_columns_tags(@query) %></td>
+        </tr>
+        <% end %>
+        <% if @query.available_totalable_columns.any? %>
+        <tr>
+          <td><%= l(:label_total_plural) %></td>
+          <td><%= available_totalable_columns_tags(@query) %></td>
+        </tr>
+        <% end %>
+      </table>
+    </div>
+  </fieldset>
+</div>
+
+<p class="buttons">
+  <%= link_to_function l(:button_apply), '$("#query_form").submit()', :class => 'icon icon-checked' %>
+  <%= link_to l(:button_clear), { :set_filter => 1, :project_id => @project }, :class => 'icon icon-reload'  %>
+  <% if @query.new_record? %>
+    <% if User.current.allowed_to?(:save_queries, @project, :global => true) %>
+      <%= link_to_function l(:button_save),
+                           "$('#query_type').prop('disabled',false);$('#query_form').attr('action', '#{ @project ? new_project_query_path(@project) : new_query_path }').submit()",
+                           :class => 'icon icon-save' %>
+    <% end %>
+  <% else %>
+    <% if @query.editable_by?(User.current) %>
+      <%= link_to l(:button_edit), edit_query_path(@query), :class => 'icon icon-edit' %>
+      <%= delete_link query_path(@query) %>
+    <% end %>
+  <% end %>
+</p>
+</div>
+
+<%= error_messages_for @query %>
index 91b96d8b7f51e9deeb75ac9c809b01cacd032669..db07c465e971e50180ff417c643a24e33fb05120 100644 (file)
@@ -1,44 +1,4 @@
-<div id="query_form_with_buttons" class="hide-when-print">
-<%= hidden_field_tag 'set_filter', '1' %>
-<div id="query_form_content">
-  <fieldset id="filters" class="collapsible <%= @query.new_record? ? "" : "collapsed" %>">
-    <legend onclick="toggleFieldset(this);"><%= l(:label_filter_plural) %></legend>
-    <div style="<%= @query.new_record? ? "" : "display: none;" %>">
-      <%= render :partial => 'queries/filters', :locals => {:query => @query} %>
-    </div>
-  </fieldset>
-  <fieldset id="options" class="collapsible collapsed">
-    <legend onclick="toggleFieldset(this);"><%= l(:label_options) %></legend>
-    <div style="display: none;">
-      <table>
-        <tr>
-          <td class="field"><%= l(:field_column_names) %></td>
-          <td><%= render_query_columns_selection(@query) %></td>
-        </tr>
-      </table>
-    </div>
-  </fieldset>
-</div>
-
-<p class="buttons">
-  <%= link_to_function l(:button_apply), '$("#query_form").submit()', :class => 'icon icon-checked' %>
-  <%= link_to l(:button_clear), {:project_id => @project, :issue_id => @issue}, :class => 'icon icon-reload'  %>
-  <% if @query.new_record? %>
-    <% if User.current.allowed_to?(:save_queries, @project, :global => true) %>
-      <%= link_to_function l(:button_save),
-                           "$('#query_form').attr('action', '#{ @project ? new_project_query_path(@project) : new_query_path }').submit()",
-                           :class => 'icon icon-save' %>
-    <% end %>
-  <% else %>
-    <% if @query.editable_by?(User.current) %>
-      <%= link_to l(:button_edit), edit_query_path(@query), :class => 'icon icon-edit' %>
-      <%= delete_link query_path(@query) %>
-    <% end %>
-  <% end %>
-</p>
-
-<%= hidden_field_tag 'type', 'TimeEntryQuery' %>
-</div>
+<%= render :partial => 'queries/query_form' %>
 
 <div class="tabs hide-when-print">
 <% query_params = request.query_parameters %>
index 71fe41a685608987d505453343ff47b593b20659..3a854ccef7f3fa0ebead412ddcce64b60b25bbf8 100644 (file)
   </tr>
 </thead>
 <tbody>
-<% entries.each do |entry| -%>
+<% grouped_query_results(entries, @query, @entry_count_by_group) do |entry, 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>
+        <span class="name"><%= group_name %></span>
+        <% if group_count %>
+        <span class="count"><%= group_count %></span>
+        <% end %>
+        <span class="totals"><%= group_totals %></span>
+        <%= link_to_function("#{l(:button_collapse_all)}/#{l(:button_expand_all)}",
+                             "toggleAllRowGroups(this)", :class => 'toggle-all') %>
+      </td>
+    </tr>
+  <% end %>
   <tr class="time-entry <%= cycle("odd", "even") %> hascontextmenu">
     <td class="checkbox hide-when-print"><%= check_box_tag("ids[]", entry.id, false, :id => nil) %></td>
     <%= raw @query.inline_columns.map {|column| "<td class=\"#{column.css_classes}\">#{column_content(column, entry)}</td>"}.join %>
     <% end -%>
     </td>
   </tr>
+  <% @query.block_columns.each do |column|
+       if (text = column_content(column, issue)) && text.present? -%>
+  <tr class="<%= current_cycle %>">
+    <td colspan="<%= @query.inline_columns.size + 1 %>" class="<%= column.css_classes %>"><%= text %></td>
+  </tr>
+  <% end -%>
+  <% end -%>
 <% end -%>
 </tbody>
 </table>
index d6ea9690ec8fff11b85ed0a29fe9eaeecfc14c81..7af8ebd83221eec8ef7ecee781a7ba642ba70eb4 100644 (file)
 <%= render :partial => 'date_range' %>
 <% end %>
 
-<div class="total-hours">
-<p><%= l(:label_total_time) %>: <%= html_hours(l_hours(@total_hours)) %></p>
-</div>
-
 <% unless @entries.empty? %>
+<%= render_query_totals(@query) %>
 <%= render :partial => 'list', :locals => { :entries => @entries }%>
 <span class="pagination"><%= pagination_links_full @entry_pages, @entry_count %></span>
 
index efa04de8808b0d41d959de8be431f0aeeed1ea72..c798d4873733d3dbafcab156dbc73240c0c8e953 100644 (file)
 <% end %>
 
 <% unless @report.criteria.empty? %>
-<div class="total-hours">
-<p><%= l(:label_total_time) %>: <%= html_hours(l_hours(@report.total_hours)) %></p>
-</div>
-
 <% unless @report.hours.empty? %>
 <div class="autoscroll">
 <table class="list" id="time-report">
index f1914329b5dd712befaf52a3e4afc114b666ead3..9816c650816571c74e437d25c479cbccda36a8ac 100644 (file)
@@ -314,9 +314,10 @@ table.query-columns td.buttons {
   text-align: center;
 }
 table.query-columns td.buttons input[type=button] {width:35px;}
-.query-totals {text-align:right; margin-top:-2.3em;}
+.query-totals {text-align:right;}
 .query-totals>span {margin-left:0.6em;}
 .query-totals .value {font-weight:bold;}
+body.controller-issues .query-totals {margin-top:-2.3em;}
 
 td.center {text-align:center;}
 
index b85ee67be34576c54c045226a6e39cdf7ca5551d..5e1af33f5e6b0d87bd4dd831a60621f09d0af90b 100644 (file)
@@ -582,9 +582,8 @@ class TimelogControllerTest < ActionController::TestCase
   def test_index_all_projects
     get :index
     assert_response :success
-    assert_template 'index'
-    assert_not_nil assigns(:total_hours)
-    assert_equal "162.90", "%.2f" % assigns(:total_hours)
+
+    assert_select '.total-for-hours', :text => 'Hours: 162.90'
     assert_select 'form#query_form[action=?]', '/time_entries'
   end
 
@@ -612,8 +611,8 @@ class TimelogControllerTest < ActionController::TestCase
     assert_equal 4, assigns(:entries).size
     # project and subproject
     assert_equal [1, 3], assigns(:entries).collect(&:project_id).uniq.sort
-    assert_not_nil assigns(:total_hours)
-    assert_equal "162.90", "%.2f" % assigns(:total_hours)
+
+    assert_select '.total-for-hours', :text => 'Hours: 162.90'
     assert_select 'form#query_form[action=?]', '/projects/ecookbook/time_entries'
   end
 
@@ -646,7 +645,7 @@ class TimelogControllerTest < ActionController::TestCase
     @request.session[:user_id] = 2
 
     get :index, :project_id => 'ecookbook', :issue_id => issue.id.to_s, :set_filter => 1
-    assert_select '.total-hours', :text => 'Total time: 7.00 hours'
+    assert_select '.total-for-hours', :text => 'Hours: 7.00'
   end
 
   def test_index_at_project_level_with_issue_fixed_version_id_short_filter
@@ -657,7 +656,7 @@ class TimelogControllerTest < ActionController::TestCase
     @request.session[:user_id] = 2
 
     get :index, :project_id => 'ecookbook', :"issue.fixed_version_id" => version.id.to_s, :set_filter => 1
-    assert_select '.total-hours', :text => 'Total time: 5.00 hours'
+    assert_select '.total-for-hours', :text => 'Hours: 5.00'
   end
 
   def test_index_at_project_level_with_date_range
@@ -669,8 +668,8 @@ class TimelogControllerTest < ActionController::TestCase
     assert_template 'index'
     assert_not_nil assigns(:entries)
     assert_equal 3, assigns(:entries).size
-    assert_not_nil assigns(:total_hours)
-    assert_equal "12.90", "%.2f" % assigns(:total_hours)
+
+    assert_select '.total-for-hours', :text => 'Hours: 12.90'
     assert_select 'form#query_form[action=?]', '/projects/ecookbook/time_entries'
   end
 
@@ -680,8 +679,8 @@ class TimelogControllerTest < ActionController::TestCase
     assert_template 'index'
     assert_not_nil assigns(:entries)
     assert_equal 3, assigns(:entries).size
-    assert_not_nil assigns(:total_hours)
-    assert_equal "12.90", "%.2f" % assigns(:total_hours)
+
+    assert_select '.total-for-hours', :text => 'Hours: 12.90'
     assert_select 'form#query_form[action=?]', '/projects/ecookbook/time_entries'
   end
 
@@ -691,9 +690,7 @@ class TimelogControllerTest < ActionController::TestCase
       :op => {'spent_on' => '>t-'},
       :v => {'spent_on' => ['7']}
     assert_response :success
-    assert_template 'index'
-    assert_not_nil assigns(:entries)
-    assert_not_nil assigns(:total_hours)
+
     assert_select 'form#query_form[action=?]', '/projects/ecookbook/time_entries'
   end