Project

General

Profile

Feature #2024 » 2024-moving-gantt-bar-v2.patch

Yuichi HARADA, 2021-02-16 08:19

View differences:

app/controllers/gantts_controller.rb
19 19

  
20 20
class GanttsController < ApplicationController
21 21
  menu_item :gantt
22
  before_action :find_optional_project
22
  before_action :find_optional_project, :only => [:show]
23 23

  
24 24
  rescue_from Query::StatementInvalid, :with => :query_statement_invalid
25 25

  
......
55 55
      end
56 56
    end
57 57
  end
58

  
59
  def change_duration
60
    return render_error(:status => :unprocessable_entity) unless request.xhr?
61

  
62
    @obj = Issue.find(params[:id])
63
    raise Unauthorized unless @obj.visible?
64

  
65
    ActiveRecord::Base.transaction do
66
      @obj.init_journal(User.current)
67
      @obj.safe_attributes = params[:change_duration]
68
      if !@obj.save
69
        render_403(:message => @obj.errors.full_messages.join)
70
        raise ActiveRecord::Rollback
71
      end
72
    end
73
    retrieve_query
74
  rescue ActiveRecord::StaleObjectError
75
    render_403(:message => :notice_issue_update_conflict)
76
  rescue ActiveRecord::RecordNotFound
77
    render_404
78
  end
58 79
end
app/views/gantts/change_duration.js.erb
1
<%
2
@draw_objs = []
3

  
4
def select_precedes(issue)
5
  issue.relations_from.where(:relation_type => IssueRelation::TYPE_PRECEDES).map(&:issue_to).each do |follows|
6
    next if @draw_objs.include?(follows)
7

  
8
    while follows do
9
      @draw_objs.concat [follows, follows.fixed_version, follows.project]
10
      select_precedes(follows)
11
      follows.children.each do |child|
12
        @draw_objs.concat [child, child.fixed_version, child.project]
13
        select_precedes(child)
14
      end
15
      follows = follows.parent
16
    end
17
  end
18
end
19

  
20
issue = @obj
21
while issue do
22
  @draw_objs.concat [issue, issue.fixed_version, issue.project]
23
  select_precedes(issue)
24
  issue = issue.parent
25
end
26
@draw_objs = @draw_objs.compact.uniq
27
@draw_objs.reject!{|obj| ![Project, Version, Issue].include?(obj.class)}
28
-%>
29
var elm;
30
<%
31
gantt = Redmine::Helpers::Gantt.new(params)
32
gantt.view = self
33
gantt.query = @query
34

  
35
@draw_objs.each do |obj|
36
  gantt.instance_variable_set(:@number_of_rows, 0)
37
  gantt.instance_variable_set(:@lines, '')
38
  gantt.render_object_row(
39
    obj,
40
    {format: :html, only: :lines, zoom: 2 ** gantt.zoom, top: 0, top_increment: 20}
41
  )
42
  todo_content = Nokogiri::HTML.parse(gantt.instance_variable_get(:@lines))
43
  todo_content = todo_content.xpath(
44
    "//div[contains(@class,'task') and contains(@class,'line')]/*"
45
  ).to_s.tr("\n",'').gsub(/'/, "\\\\'")
46

  
47
  klass_name = obj.class.name.underscore
48
  elm_todo = "[id=task-todo-#{klass_name}-#{obj.id}]"
49
  css_subject = 'span:not(.expander)'
50
  elm_subject = raw("[id=#{klass_name}-#{obj.id}] > #{css_subject}")
51

  
52
  subject_content = Nokogiri::HTML.parse(gantt.__send__(:html_subject_content, obj))
53
  subject_content = subject_content.css(css_subject).to_s.tr("\n",'').gsub(/'/, "\\\\'")
54
-%>
55
if($('<%= elm_subject %>').length){
56
  $('<%= elm_todo %>').each(function(_, task) {
57
    var el_parent = $(task).parent();
58
    el_parent.html('<%= raw(todo_content) %>');
59
    var number_of_rows = el_parent.attr('data-number-of-rows');
60
    if(number_of_rows){
61
      el_parent.find('div[data-number-of-rows]').attr('data-number-of-rows', number_of_rows);
62
    }
63
  });
64
  $('<%= elm_subject %>').replaceWith('<%= raw(subject_content) %>');
65
<%
66
  case obj
67
  when Issue
68
    @query.columns.each do |column|
69
-%>
70
  elm = $('div.gantt_selected_column_content #<%= column.name %>_issue_<%= obj.id %>');
71
  if(elm.length){
72
    elm.html('<%= escape_javascript(column_content(column, obj)) %>');
73
  }
74
<%
75
    end
76
  end
77
-%>
78
}
79
<%
80
end
81
-%>
config/routes.rb
60 60

  
61 61
  get '/projects/:project_id/issues/gantt', :to => 'gantts#show', :as => 'project_gantt'
62 62
  get '/issues/gantt', :to => 'gantts#show'
63
  put '/gantt/:id/change_duration', :to => 'gantts#change_duration', :as => 'gantt_change_duration'
63 64

  
64 65
  get '/projects/:project_id/issues/calendar', :to => 'calendars#show', :as => 'project_calendar'
65 66
  get '/issues/calendar', :to => 'calendars#show'
lib/redmine/helpers/gantt.rb
347 347
        if options[:format] == :html
348 348
          data_options = {}
349 349
          data_options[:collapse_expand] = "issue-#{issue.id}"
350
          data_options[:number_of_rows] = number_of_rows
350 351
          style = "position: absolute;top: #{options[:top]}px; font-size: 0.8em;"
351 352
          content =
352 353
            view.content_tag(
......
771 772
          tag_options[:class] = "version-name"
772 773
          has_children = object.fixed_issues.exists?
773 774
        when Project
775
          tag_options[:id] = "project-#{object.id}"
774 776
          tag_options[:class] = "project-name"
775 777
          has_children = object.issues.exists? || object.versions.exists?
776 778
        end
......
780 782
              :top_increment => params[:top_increment],
781 783
              :obj_id => "#{object.class}-#{object.id}".downcase,
782 784
            },
785
            :number_of_rows => number_of_rows,
783 786
          }
784 787
        end
785 788
        if has_children
......
835 838
      def html_task(params, coords, markers, label, object)
836 839
        output = +''
837 840
        data_options = {}
838
        data_options[:collapse_expand] = "#{object.class}-#{object.id}".downcase if object
841
        if object
842
          data_options[:collapse_expand] = "#{object.class}-#{object.id}".downcase
843
          data_options[:number_of_rows] = number_of_rows
844
        end
839 845
        css = "task " +
840 846
          case object
841 847
          when Project
......
849 855
          end
850 856
        # Renders the task bar, with progress and late
851 857
        if coords[:bar_start] && coords[:bar_end]
852
          width = coords[:bar_end] - coords[:bar_start] - 2
858
          width = coords[:bar_end] - coords[:bar_start]
853 859
          style = +""
854
          style << "top:#{params[:top]}px;"
855 860
          style << "left:#{coords[:bar_start]}px;"
856 861
          style << "width:#{width}px;"
857
          html_id = "task-todo-issue-#{object.id}" if object.is_a?(Issue)
858
          html_id = "task-todo-version-#{object.id}" if object.is_a?(Version)
862
          html_id =
863
            case object
864
            when Project
865
              "task-todo-project-#{object.id}"
866
            when Version
867
              "task-todo-version-#{object.id}"
868
            when Issue
869
              "task-todo-issue-#{object.id}"
870
            end
859 871
          content_opt = {:style => style,
860
                         :class => "#{css} task_todo",
872
                         :class => "task_todo",
861 873
                         :id => html_id,
862 874
                         :data => {}}
863 875
          if object.is_a?(Issue)
......
865 877
            if rels.present?
866 878
              content_opt[:data] = {"rels" => rels.to_json}
867 879
            end
880
            content_opt[:data].merge!({
881
              :url_change_duration => Rails.application.routes.url_helpers.gantt_change_duration_path(
882
                object
883
              ),
884
              :object => {
885
                :start_date => object.start_date,
886
                :due_date => object.due_date,
887
                :lock_version => object.lock_version,
888
              }.to_json,
889
            })
868 890
          end
869 891
          content_opt[:data].merge!(data_options)
870
          output << view.content_tag(:div, '&nbsp;'.html_safe, content_opt)
892
          bar_contents = []
871 893
          if coords[:bar_late_end]
872
            width = coords[:bar_late_end] - coords[:bar_start] - 2
894
            width = coords[:bar_late_end] - coords[:bar_start]
873 895
            style = +""
874
            style << "top:#{params[:top]}px;"
875
            style << "left:#{coords[:bar_start]}px;"
876 896
            style << "width:#{width}px;"
877
            output << view.content_tag(:div, '&nbsp;'.html_safe,
878
                                       :style => style,
879
                                       :class => "#{css} task_late",
880
                                       :data => data_options)
897
            bar_contents << view.content_tag(:div, '&nbsp;'.html_safe,
898
                                             :style => style,
899
                                             :class => "task_late",
900
                                             :data => data_options)
881 901
          end
882 902
          if coords[:bar_progress_end]
883
            width = coords[:bar_progress_end] - coords[:bar_start] - 2
903
            width = coords[:bar_progress_end] - coords[:bar_start]
884 904
            style = +""
885
            style << "top:#{params[:top]}px;"
886
            style << "left:#{coords[:bar_start]}px;"
887 905
            style << "width:#{width}px;"
888 906
            html_id = "task-done-issue-#{object.id}" if object.is_a?(Issue)
889 907
            html_id = "task-done-version-#{object.id}" if object.is_a?(Version)
890
            output << view.content_tag(:div, '&nbsp;'.html_safe,
908
            bar_contents << view.content_tag(:div, '&nbsp;'.html_safe,
909
                                             :style => style,
910
                                             :class => "task_done",
911
                                             :id => html_id,
912
                                             :data => data_options)
913
          end
914

  
915
          # Renders the tooltip
916
          if object.is_a?(Issue)
917
            s = view.content_tag(:span,
918
                               view.render_issue_tooltip(object).html_safe,
919
                               :class => "tip")
920
            s += view.content_tag(:input, nil, :type => 'checkbox', :name => 'ids[]', :value => object.id, :style => 'display:none;', :class => 'toggle-selection')
921
            style = +""
922
            style << "width:#{coords[:bar_end] - coords[:bar_start]}px;"
923
            style << "height:12px;"
924
            bar_contents << view.content_tag(:div, s.html_safe,
925
                                             :style => style,
926
                                             :class => "tooltip hascontextmenu",
927
                                             :data => data_options)
928
          end
929

  
930
          # Renders the label on the right
931
          if label
932
            style = +""
933
            style << "top:0px;"
934
            style << "left:#{coords[:bar_end] - coords[:bar_start] + 8}px;"
935
            bar_contents << view.content_tag(:div, label,
936
                                             :style => style,
937
                                             :class => "label",
938
                                             :data => data_options)
939
          end
940

  
941
          bar_contents = bar_contents.join.presence
942
          output << view.content_tag(:div, (bar_contents || '&nbsp;').html_safe, content_opt)
943
        else
944
          # Renders the label on the right
945
          if label
946
            style = +""
947
            style << "top:1px;"
948
            style << "left:#{(coords[:bar_end] || 0) + 8}px;"
949
            output << view.content_tag(:div, label,
891 950
                                       :style => style,
892
                                       :class => "#{css} task_done",
893
                                       :id => html_id,
951
                                       :class => "label",
894 952
                                       :data => data_options)
895 953
          end
896 954
        end
......
898 956
        if markers
899 957
          if coords[:start]
900 958
            style = +""
901
            style << "top:#{params[:top]}px;"
902 959
            style << "left:#{coords[:start]}px;"
903
            style << "width:15px;"
904 960
            output << view.content_tag(:div, '&nbsp;'.html_safe,
905 961
                                       :style => style,
906
                                       :class => "#{css} marker starting",
962
                                       :class => "marker starting",
907 963
                                       :data => data_options)
908 964
          end
909 965
          if coords[:end]
910 966
            style = +""
911
            style << "top:#{params[:top]}px;"
912 967
            style << "left:#{coords[:end]}px;"
913
            style << "width:15px;"
914 968
            output << view.content_tag(:div, '&nbsp;'.html_safe,
915 969
                                       :style => style,
916
                                       :class => "#{css} marker ending",
970
                                       :class => "marker ending",
917 971
                                       :data => data_options)
918 972
          end
919 973
        end
920
        # Renders the label on the right
921
        if label
922
          style = +""
923
          style << "top:#{params[:top]}px;"
924
          style << "left:#{(coords[:bar_end] || 0) + 8}px;"
925
          style << "width:15px;"
926
          output << view.content_tag(:div, label,
927
                                     :style => style,
928
                                     :class => "#{css} label",
929
                                     :data => data_options)
930
        end
931
        # Renders the tooltip
932
        if object.is_a?(Issue) && coords[:bar_start] && coords[:bar_end]
933
          s = view.content_tag(:span,
934
                               view.render_issue_tooltip(object).html_safe,
935
                               :class => "tip")
936
          s += view.content_tag(:input, nil, :type => 'checkbox', :name => 'ids[]',
937
                                :value => object.id, :style => 'display:none;',
938
                                :class => 'toggle-selection')
939
          style = +""
940
          style << "position: absolute;"
941
          style << "top:#{params[:top]}px;"
942
          style << "left:#{coords[:bar_start]}px;"
943
          style << "width:#{coords[:bar_end] - coords[:bar_start]}px;"
944
          style << "height:12px;"
945
          output << view.content_tag(:div, s.html_safe,
946
                                     :style => style,
947
                                     :class => "tooltip hascontextmenu",
948
                                     :data => data_options)
949
        end
974
        output = view.content_tag(:div, output.html_safe,
975
          :class => "#{css} line",
976
          :style => "top:#{params[:top]}px;width:#{params[:g_width] - 1}px;",
977
          :data => data_options
978
        )
950 979
        @lines << output
951 980
        output
952 981
      end
public/javascripts/gantt.js
4 4
var draw_gantt = null;
5 5
var draw_top;
6 6
var draw_right;
7
var draw_left;
8 7

  
9 8
var rels_stroke_width = 2;
10 9

  
11 10
function setDrawArea() {
12
  draw_top   = $("#gantt_draw_area").position().top;
11
  draw_top   = $("#gantt_draw_area").offset().top;
13 12
  draw_right = $("#gantt_draw_area").width();
14
  draw_left  = $("#gantt_area").scrollLeft();
15 13
}
16 14

  
17 15
function getRelationsArray() {
......
42 40
      return;
43 41
    }
44 42
    var issue_height = issue_from.height();
45
    var issue_from_top   = issue_from.position().top  + (issue_height / 2) - draw_top;
43
    var issue_from_top   = issue_from.offset().top  + (issue_height / 2) - draw_top;
46 44
    var issue_from_right = issue_from.position().left + issue_from.width();
47
    var issue_to_top   = issue_to.position().top  + (issue_height / 2) - draw_top;
45
    var issue_to_top   = issue_to.offset().top  + (issue_height / 2) - draw_top;
48 46
    var issue_to_left  = issue_to.position().left;
49 47
    var color = issue_relation_type[element_issue["rel_type"]]["color"];
50 48
    var landscape_margin = issue_relation_type[element_issue["rel_type"]]["landscape_margin"];
51 49
    var issue_from_right_rel = issue_from_right + landscape_margin;
52 50
    var issue_to_left_rel    = issue_to_left    - landscape_margin;
53
    draw_gantt.path(["M", issue_from_right + draw_left,     issue_from_top,
54
                     "L", issue_from_right_rel + draw_left, issue_from_top])
51
    draw_gantt.path(["M", issue_from_right,     issue_from_top,
52
                     "L", issue_from_right_rel, issue_from_top])
55 53
                   .attr({stroke: color,
56 54
                          "stroke-width": rels_stroke_width
57 55
                          });
58 56
    if (issue_from_right_rel < issue_to_left_rel) {
59
      draw_gantt.path(["M", issue_from_right_rel + draw_left, issue_from_top,
60
                       "L", issue_from_right_rel + draw_left, issue_to_top])
57
      draw_gantt.path(["M", issue_from_right_rel, issue_from_top,
58
                       "L", issue_from_right_rel, issue_to_top])
61 59
                     .attr({stroke: color,
62 60
                          "stroke-width": rels_stroke_width
63 61
                          });
64
      draw_gantt.path(["M", issue_from_right_rel + draw_left, issue_to_top,
65
                       "L", issue_to_left + draw_left,        issue_to_top])
62
      draw_gantt.path(["M", issue_from_right_rel, issue_to_top,
63
                       "L", issue_to_left,        issue_to_top])
66 64
                     .attr({stroke: color,
67 65
                          "stroke-width": rels_stroke_width
68 66
                          });
......
70 68
      var issue_middle_top = issue_to_top +
71 69
                                (issue_height *
72 70
                                   ((issue_from_top > issue_to_top) ? 1 : -1));
73
      draw_gantt.path(["M", issue_from_right_rel + draw_left, issue_from_top,
74
                       "L", issue_from_right_rel + draw_left, issue_middle_top])
71
      draw_gantt.path(["M", issue_from_right_rel, issue_from_top,
72
                       "L", issue_from_right_rel, issue_middle_top])
75 73
                     .attr({stroke: color,
76 74
                          "stroke-width": rels_stroke_width
77 75
                          });
78
      draw_gantt.path(["M", issue_from_right_rel + draw_left, issue_middle_top,
79
                       "L", issue_to_left_rel + draw_left,    issue_middle_top])
76
      draw_gantt.path(["M", issue_from_right_rel, issue_middle_top,
77
                       "L", issue_to_left_rel,    issue_middle_top])
80 78
                     .attr({stroke: color,
81 79
                          "stroke-width": rels_stroke_width
82 80
                          });
83
      draw_gantt.path(["M", issue_to_left_rel + draw_left, issue_middle_top,
84
                       "L", issue_to_left_rel + draw_left, issue_to_top])
81
      draw_gantt.path(["M", issue_to_left_rel, issue_middle_top,
82
                       "L", issue_to_left_rel, issue_to_top])
85 83
                     .attr({stroke: color,
86 84
                          "stroke-width": rels_stroke_width
87 85
                          });
88
      draw_gantt.path(["M", issue_to_left_rel + draw_left, issue_to_top,
89
                       "L", issue_to_left + draw_left,     issue_to_top])
86
      draw_gantt.path(["M", issue_to_left_rel, issue_to_top,
87
                       "L", issue_to_left,     issue_to_top])
90 88
                     .attr({stroke: color,
91 89
                          "stroke-width": rels_stroke_width
92 90
                          });
93 91
    }
94
    draw_gantt.path(["M", issue_to_left + draw_left, issue_to_top,
92
    draw_gantt.path(["M", issue_to_left, issue_to_top,
95 93
                     "l", -4 * rels_stroke_width, -2 * rels_stroke_width,
96 94
                     "l", 0, 4 * rels_stroke_width, "z"])
97 95
                   .attr({stroke: "none",
......
104 102

  
105 103
function getProgressLinesArray() {
106 104
  var arr = new Array();
107
  var today_left = $('#today_line').position().left;
105
  var today_left = $('#today_line').position().left + $("#gantt_area").scrollLeft();
108 106
  arr.push({left: today_left, top: 0});
109 107
  $.each($('div.issue-subject, div.version-name'), function(index, element) {
110 108
    if(!$(element).is(':visible')) return true;
111
    var t = $(element).position().top - draw_top ;
109
    var t = $(element).offset().top - draw_top ;
112 110
    var h = ($(element).height() / 9);
113 111
    var element_top_upper  = t - h;
114 112
    var element_top_center = t + (h * 3);
......
125 123
        arr.push({left: draw_right, top: element_top_upper, is_right_edge: true});
126 124
        arr.push({left: draw_right, top: element_top_lower, is_right_edge: true, none_stroke: true});
127 125
      } else if (issue_done.length > 0) {
128
        var done_left = issue_done.first().position().left +
129
                           issue_done.first().width();
126
        var done_left = today_left;
127
        var issue_todo = $("#task-todo-" + $(element).attr("id"));
128
        if (issue_todo.length > 0){
129
          done_left = issue_todo.first().position().left;
130
        }
130 131
        arr.push({left: done_left, top: element_top_center});
131 132
      } else if (is_behind_start) {
132 133
        arr.push({left: 0 , top: element_top_upper, is_left_edge: true});
......
145 146
}
146 147

  
147 148
function drawGanttProgressLines() {
149
  if(!$("#today_line").length) return;
148 150
  var arr = getProgressLinesArray();
149 151
  var color = $("#today_line")
150 152
                    .css("border-left-color");
......
154 156
        (!("is_right_edge" in arr[i - 1] && "is_right_edge" in arr[i]) &&
155 157
         !("is_left_edge"  in arr[i - 1] && "is_left_edge"  in arr[i]))
156 158
        ) {
157
      var x1 = (arr[i - 1].left == 0) ? 0 : arr[i - 1].left + draw_left;
158
      var x2 = (arr[i].left == 0)     ? 0 : arr[i].left     + draw_left;
159
      var x1 = (arr[i - 1].left == 0) ? 0 : arr[i - 1].left;
160
      var x2 = (arr[i].left == 0)     ? 0 : arr[i].left;
159 161
      draw_gantt.path(["M", x1, arr[i - 1].top,
160 162
                       "L", x2, arr[i].top])
161 163
                   .attr({stroke: color, "stroke-width": 2});
......
253 255
  subject.nextAll('div').each(function(_, element){
254 256
    var el = $(element);
255 257
    var json = el.data('collapse-expand');
258
    var number_of_rows = el.data('number-of-rows');
259
    var el_task_bars = '#gantt_area form > div[data-collapse-expand="' + json.obj_id + '"][data-number-of-rows="' + number_of_rows + '"]';
260
    var el_selected_columns = 'td.gantt_selected_column div[data-collapse-expand="' + json.obj_id + '"][data-number-of-rows="' + number_of_rows + '"]';
256 261
    if(out_of_hierarchy || parseInt(el.css('left')) <= subject_left){
257 262
      out_of_hierarchy = true;
258 263
      if(target_shown == null) return false;
259 264

  
260 265
      var new_top_val = parseInt(el.css('top')) + total_height * (target_shown ? -1 : 1);
261 266
      el.css('top', new_top_val);
262
      $('#gantt_area form > div[data-collapse-expand="' + json.obj_id + '"], td.gantt_selected_column div[data-collapse-expand="' + json.obj_id + '"]').each(function(_, el){
267
      $([el_task_bars, el_selected_columns].join()).each(function(_, el){
263 268
        $(el).css('top', new_top_val);
264 269
      });
265 270
      return true;
......
272 277
      total_height = 0;
273 278
    }
274 279
    if(is_shown == target_shown){
275
      $('#gantt_area form > div[data-collapse-expand="' + json.obj_id + '"]').each(function(_, task) {
280
      $(el_task_bars).each(function(_, task) {
276 281
        var el_task = $(task);
277 282
        if(!is_shown)
278 283
          el_task.css('top', target_top + total_height);
279 284
        if(!el_task.hasClass('tooltip'))
280 285
          el_task.toggle(!is_shown);
281 286
      });
282
      $('td.gantt_selected_column div[data-collapse-expand="' + json.obj_id + '"]'
283
          ).each(function (_, attr) {
287
      $(el_selected_columns).each(function (_, attr) {
284 288
        var el_attr = $(attr);
285 289
        if (!is_shown)
286 290
          el_attr.css('top', target_top + total_height);
......
301 305
    $('#available_c, #selected_c').children("[value='" + value + "']").prop('disabled', true);
302 306
  });
303 307
}
308

  
309
initGanttDnD = function(){
310
  var grid_x = 0;
311
  if($('#zoom').length){
312
    switch(parseInt($('#zoom').val())){
313
    case 4:
314
      grid_x = 16;
315
      break;
316
    case 3:
317
      grid_x = 8;
318
      break;
319
    }
320
  }
321
  if(grid_x > 0){
322
    $('.leaf .task_todo').draggable({
323
      containment: 'parent',
324
      axis: 'x',
325
      grid: [grid_x, 0],
326
      opacity: 0.5,
327
      cursor: 'move',
328
      revertDuration: 100,
329
      start: function (event, ui) {
330
        var helper = ui.helper[0];
331
        helper.startLeft = ui.position.left;
332
      },
333
    });
334

  
335
    $('.task.line').droppable({
336
      accept: '.leaf .task_todo',
337
      drop: function (event, ui) {
338
        var target = $(ui.draggable);
339
        var url = target.attr('data-url-change-duration');
340
        var object = JSON.parse(target.attr('data-object'));
341
        var startLeft = target[0].startLeft;
342
        var relative_days = Math.floor((ui.position.left - startLeft) / grid_x);
343
        if(relative_days == 0){
344
          return;
345
        }
346
        var start_date = new Date(object.start_date);
347
        start_date.setDate(start_date.getDate() + relative_days);
348
        start_date =
349
          [
350
            start_date.getFullYear(),
351
            ('0' + (start_date.getMonth() + 1)).slice(-2),
352
            ('0' + start_date.getDate()).slice(-2),
353
          ].join('-');
354
        var due_date = null;
355
        if(object.due_date != null){
356
          due_date = new Date(object.due_date);
357
          due_date.setDate(due_date.getDate() + relative_days);
358
          due_date =
359
            [
360
              due_date.getFullYear(),
361
              ('0' + (due_date.getMonth() + 1)).slice(-2),
362
              ('0' + due_date.getDate()).slice(-2),
363
            ].join('-');
364
        }
365

  
366
        $('#selected_c option:not(:disabled)').prop('selected', true);
367
        var form = $('#query_form').serializeArray();
368
        var json_param = {};
369
        form.forEach(function(data){
370
          var key = data.name;
371
          var value = data.value;
372
          if(/\[\]$/.test(key)){
373
            if(!json_param.hasOwnProperty(key)){
374
              json_param[key] = [];
375
            }
376
            json_param[key].push(value);
377
          }
378
          else{
379
            json_param[key] = value;
380
          }
381
        });
382
        $('#selected_c option:not(:disabled)').prop('selected', false);
383
        Object.assign(json_param, {
384
          change_duration: {
385
            start_date: start_date,
386
            due_date: due_date,
387
            lock_version: object.lock_version,
388
          },
389
        });
390

  
391
        $.ajax({
392
          type: 'PUT',
393
          url: url,
394
          data: json_param,
395
        }).done(function(data){
396
          drawGanttHandler();
397
          initGanttDnD();
398
        }).fail(function(jqXHR){
399
          var contents = $('<div>' + jqXHR.responseText + '</div>');
400
          var error_message = contents.find('p#errorExplanation');
401
          if(error_message.length){
402
            $('div#content h2:first-of-type').after(error_message);
403
            $('p#errorExplanation').hide('fade', {}, 3000, function(){
404
              $(this).remove();
405
            });
406
          }
407
          ui.draggable.animate({'left': ui.draggable[0].startLeft}, 'fast');
408
        });
409
      }
410
    });
411
  }
412
};
413

  
414
$(document).ready(initGanttDnD);
public/stylesheets/application.css
1410 1410

  
1411 1411
.task {
1412 1412
  position: absolute;
1413
  height:8px;
1413
  height:10px;
1414 1414
  font-size:0.8em;
1415 1415
  color:#888;
1416 1416
  padding:0;
......
1419 1419
  white-space:nowrap;
1420 1420
}
1421 1421

  
1422
.task.label {width:100%;}
1423
.task.label.project, .task.label.version { font-weight: bold; }
1422
.task.line { left: 0; }
1423
.task div.tooltip:hover span.tip { font-size: inherit; }
1424
.task .task_todo .label { font-size: inherit; }
1425
.task.project .task_todo .label { margin-top: -4px; }
1426
.task.version .task_todo .label { margin-top: -3px; }
1424 1427

  
1425
.task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
1426
.task_done { background:#00c600 url(../images/task_done.png); border: 1px solid #00c600; }
1427
.task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
1428
.task .label { position: absolute; width: auto; }
1429
.task.project .label, .task.version .label { font-weight: bold; }
1428 1430

  
1429
.task_todo.parent { background: #888; border: 1px solid #888; height: 3px;}
1430
.task_late.parent, .task_done.parent { height: 3px;}
1431
.task.parent.marker.starting  { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; left: 0px; top: -1px;}
1432
.task.parent.marker.ending { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; right: 0px; top: -1px;}
1431
.task_late { position: absolute; height: inherit; background:#f66; }
1432
.task_done { position: absolute; height: inherit; background:#00c600; }
1433
.task_todo { position: absolute; height: inherit; background:#aaa; }
1433 1434

  
1434
.version.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
1435
.version.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
1436
.version.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
1437
.version.marker { background-image:url(../images/version_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
1435
.parent .task_todo { background: #888; height: 5px; }
1436
.parent .task_late, .parent .task_done { height: 5px; }
1437
.parent .marker {
1438
  background: #888;
1439
  display: inline-block;
1440
  position: absolute;
1441
  width: 8px;
1442
  height: 6px;
1443
  margin-left: -5px;
1444
  margin-bottom: -4px;
1445
}
1446
.parent .marker:after {
1447
  border-top: 3px solid #888;
1448
  border-left: 4px solid transparent;
1449
  border-right: 4px solid transparent;
1450
  content: '';
1451
  height: 0;
1452
  left: 0;
1453
  position: absolute;
1454
  bottom: -3px;
1455
  width: 0;
1456
}
1457

  
1458
.version .task_late { background:#f66; height: 4px; }
1459
.version .task_done { background:#00c600; height: 4px; }
1460
.version .task_todo { background:#aaa; height: 4px; margin-top: 3px; }
1461
.version .marker {
1462
  width: 0;
1463
  height: 0;
1464
  border: 5px solid transparent;
1465
  border-bottom-color: black;
1466
  position: absolute;
1467
  margin-top: -5px;
1468
  margin-left: -6px;
1469
}
1470
.version .marker:after {
1471
  content: '';
1472
  position: absolute;
1473
  left: -5px;
1474
  top: 5px;
1475
  width: 0;
1476
  height: 0;
1477
  border: 5px solid transparent;
1478
  border-top-color: black;
1479
}
1438 1480

  
1439
.project.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
1440
.project.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
1441
.project.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
1442
.project.marker { background-image:url(../images/project_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
1481
.project .task_late { background:#f66; height: 2px; }
1482
.project .task_done { background:#00c600; height: 2px; }
1483
.project .task_todo { background:#aaa; height: 2px; margin-top: 4px; }
1484
.project .marker {
1485
  width: 0;
1486
  height: 0;
1487
  border: 5px solid transparent;
1488
  border-bottom-color: blue;
1489
  position: absolute;
1490
  margin-top: -5px;
1491
  margin-left: -6px;
1492
}
1493
.project .marker:after {
1494
  content: '';
1495
  position: absolute;
1496
  left: -5px;
1497
  top: 5px;
1498
  width: 0;
1499
  height: 0;
1500
  border: 5px solid transparent;
1501
  border-top-color: blue;
1502
}
1443 1503

  
1444 1504
.version-behind-schedule a, .issue-behind-schedule a {color: #f66914;}
1445 1505
.version-overdue a, .issue-overdue a, .project-overdue a {color: #f00;}
test/integration/routing/gantts_test.rb
26 26

  
27 27
    should_route 'GET /projects/foo/issues/gantt' => 'gantts#show', :project_id => 'foo'
28 28
    should_route 'GET /projects/foo/issues/gantt.pdf' => 'gantts#show', :project_id => 'foo', :format => 'pdf'
29

  
30
    should_route 'PUT /gantt/123/change_duration' => 'gantts#change_duration', :id => '123'
29 31
  end
30 32
end
test/unit/lib/redmine/helpers/gantt_test.rb
235 235
    @project.issues << @issue
236 236
    @output_buffer = @gantt.lines
237 237

  
238
    assert_select "div.project.task_todo"
239
    assert_select "div.project.starting"
240
    assert_select "div.project.ending"
241
    assert_select "div.label.project", /#{@project.name}/
238
    assert_select "div.task.project" do
239
      assert_select "> div.task_todo" do
240
        assert_select "> div.label", /#{@project.name}/
241
      end
242
      assert_select "> div.starting"
243
      assert_select "> div.ending"
244
    end
242 245

  
243
    assert_select "div.version.task_todo"
244
    assert_select "div.version.starting"
245
    assert_select "div.version.ending"
246
    assert_select "div.label.version", /#{@version.name}/
246
    assert_select "div.task.version" do
247
      assert_select "> div.task_todo" do
248
        assert_select "div.label", /#{@version.name}/
249
      end
250
      assert_select "> div.starting"
251
      assert_select "> div.ending"
252
    end
247 253

  
248
    assert_select "div.task_todo"
249
    assert_select "div.task.label", /#{@issue.done_ratio}/
250
    assert_select "div.tooltip", /#{@issue.subject}/
254
    assert_select "div.task" do
255
      assert_select "> div.task_todo" do
256
        assert_select "> div.label", /#{@issue.done_ratio}/
257
        assert_select "> div.tooltip", /#{@issue.subject}/
258
      end
259
    end
251 260
  end
252 261

  
253 262
  test "#selected_column_content" do
......
331 340
    @project.stubs(:start_date).returns(today - 7)
332 341
    @project.stubs(:due_date).returns(today + 7)
333 342
    @output_buffer = @gantt.line_for_project(@project, :format => :html)
334
    assert_select "div.project.label", :text => @project.name
343
    assert_select "div.task.project > div.task_todo" do
344
      assert_select "> div.label", :text => @project.name
345
    end
335 346
  end
336 347

  
337 348
  test "#line_for_version" do
......
341 352
    version.stubs(:due_date).returns(today + 7)
342 353
    version.stubs(:visible_fixed_issues => stub(:completed_percent => 30))
343 354
    @output_buffer = @gantt.line_for_version(version, :format => :html)
344
    assert_select "div.version.label", :text => /Foo/
345
    assert_select "div.version.label", :text => /30%/
355
    assert_select "div.task.version > div.task_todo" do
356
      assert_select "> div.label", :text => 'Foo 30%'
357
    end
346 358
  end
347 359

  
348 360
  test "#line_for_issue" do
349 361
    create_gantt
350 362
    issue = Issue.generate!(:project => @project, :start_date => today - 7, :due_date => today + 7, :done_ratio => 30)
351 363
    @output_buffer = @gantt.line_for_issue(issue, :format => :html)
352
    assert_select "div.task.label", :text => /#{issue.status.name}/
353
    assert_select "div.task.label", :text => /30%/
354
    assert_select "div.tooltip", /#{issue.subject}/
364
    assert_select "div.task_todo" do
365
      assert_select "> div.label", :text => "#{issue.status.name} 30%"
366
      assert_select "> div.tooltip", /#{issue.subject}/
367
    end
355 368
  end
356 369

  
357 370
  test "#line todo line should start from the starting point on the left" do
......
365 378
    [gantt_start - 1, gantt_start].each do |start_date|
366 379
      @output_buffer = @gantt.line(start_date, gantt_start, 30, false, 'line', :format => :html, :zoom => 4)
367 380
      # the leftmost date (Date.today - 14 days)
368
      assert_select 'div.task_todo[style*="left:0px"]', 1, @output_buffer
369
      assert_select 'div.task_todo[style*="width:2px"]', 1, @output_buffer
381
      assert_select 'div.task_todo[style*="left:0px"][style*="width:4px"]', 1, @output_buffer
370 382
    end
371 383
  end
372 384

  
......
375 387
    [gantt_end, gantt_end + 1].each do |end_date|
376 388
      @output_buffer = @gantt.line(gantt_end, end_date, 30, false, 'line', :format => :html, :zoom => 4)
377 389
      # the rightmost date (Date.today + 14 days)
378
      assert_select 'div.task_todo[style*="left:112px"]', 1, @output_buffer
379
      assert_select 'div.task_todo[style*="width:2px"]', 1, @output_buffer
390
      assert_select 'div.task_todo[style*="left:112px"][style*="width:4px"]', 1, @output_buffer
380 391
    end
381 392
  end
382 393

  
383 394
  test "#line todo line should be the total width" do
384 395
    create_gantt
385 396
    @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4)
386
    assert_select 'div.task_todo[style*="width:58px"]', 1
397
    assert_select 'div.task_todo[style*="width:60px"]', 1
387 398
  end
388 399

  
389 400
  test "#line late line should start from the starting point on the left" do
390 401
    create_gantt
391 402
    @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4)
392
    assert_select 'div.task_late[style*="left:28px"]', 1
403
    assert_select 'div.task_todo[style*="left:28px"]' do
404
      assert_select '> div.task_late', 1
405
    end
393 406
  end
394 407

  
395 408
  test "#line late line should be the total delayed width" do
396 409
    create_gantt
397 410
    @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4)
398
    assert_select 'div.task_late[style*="width:30px"]', 1
411
    assert_select 'div.task_late[style*="width:32px"]', 1
399 412
  end
400 413

  
401 414
  test "#line late line should be the same width as task_todo if start date and end date are the same day" do
402 415
    create_gantt
403 416
    @output_buffer = @gantt.line(today - 7, today - 7, 0, false, 'line', :format => :html, :zoom => 4)
404
    assert_select 'div.task_late[style*="width:2px"]', 1
405
    assert_select 'div.task_todo[style*="width:2px"]', 1
417
    assert_select 'div.task_todo[style*="width:4px"]' do
418
      assert_select '> div.task_late[style*="width:4px"]', 1
419
    end
406 420
  end
407 421

  
408 422
  test "#line late line should be the same width as task_todo if start date and today are the same day" do
409 423
    create_gantt
410 424
    @output_buffer = @gantt.line(today, today, 0, false, 'line', :format => :html, :zoom => 4)
411
    assert_select 'div.task_late[style*="width:2px"]', 1
412
    assert_select 'div.task_todo[style*="width:2px"]', 1
425
    assert_select 'div.task_todo[style*="width:4px"]' do
426
      assert_select '> div.task_late[style*="width:4px"]', 1
427
    end
413 428
  end
414 429

  
415 430
  test "#line done line should start from the starting point on the left" do
416 431
    create_gantt
417 432
    @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4)
418
    assert_select 'div.task_done[style*="left:28px"]', 1
433
    assert_select 'div.task_todo[style*="left:28px"]' do
434
      assert_select '> div.task_done', 1
435
    end
419 436
  end
420 437

  
421 438
  test "#line done line should be the width for the done ratio" do
422 439
    create_gantt
423 440
    @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4)
424
    # 15 days * 4 px * 30% - 2 px for borders = 16 px
425
    assert_select 'div.task_done[style*="width:16px"]', 1
441
    # 15 days * 4 px * 30% = 18 px
442
    assert_select 'div.task_done[style*="width:18px"]', 1
426 443
  end
427 444

  
428 445
  test "#line done line should be the total width for 100% done ratio" do
429 446
    create_gantt
430 447
    @output_buffer = @gantt.line(today - 7, today + 7, 100, false, 'line', :format => :html, :zoom => 4)
431
    # 15 days * 4 px - 2 px for borders = 58 px
432
    assert_select 'div.task_done[style*="width:58px"]', 1
448
    # 15 days * 4 px = 60 px
449
    assert_select 'div.task_done[style*="width:60px"]', 1
433 450
  end
434 451

  
435 452
  test "#line done line should be the total width for 100% done ratio with same start and end dates" do
436 453
    create_gantt
437 454
    @output_buffer = @gantt.line(today + 7, today + 7, 100, false, 'line', :format => :html, :zoom => 4)
438
    assert_select 'div.task_done[style*="width:2px"]', 1
455
    assert_select 'div.task_done[style*="width:4px"]', 1
439 456
  end
440 457

  
441 458
  test "#line done line should not be the total done width if the gantt starts after start date" do
442 459
    create_gantt
443 460
    @output_buffer = @gantt.line(today - 16, today - 2, 30, false, 'line', :format => :html, :zoom => 4)
444
    assert_select 'div.task_done[style*="left:0px"]', 1
445
    assert_select 'div.task_done[style*="width:8px"]', 1
461
    assert_select 'div.task_todo[style*="left:0px"]' do
462
      assert_select '> div.task_done[style*="width:10px"]', 1
463
    end
446 464
  end
447 465

  
448 466
  test "#line starting marker should appear at the start date" do
(31-31/35)