From 0460b58e06f7f1df3ff7fd12a3f5b6f5d85e1549 Mon Sep 17 00:00:00 2001
From: Taro Matsuzawa <btm@tech.email.ne.jp>
Date: Tue, 26 Mar 2024 05:26:58 +0900
Subject: [PATCH] refs: #2024 integrate gantt#change_duration

---
 app/controllers/gantts_controller.rb        |  29 ++-
 app/views/gantts/change_duration.js.erb     |  30 +++
 config/routes.rb                            |   1 +
 lib/redmine/helpers/gantt.rb                | 196 ++++++++++++++------
 public/images/milestone_done.png            | Bin 137 -> 0 bytes
 public/images/milestone_late.png            | Bin 160 -> 0 bytes
 public/images/milestone_todo.png            | Bin 155 -> 0 bytes
 public/images/project_marker.png            | Bin 204 -> 0 bytes
 public/images/task_done.png                 | Bin 137 -> 0 bytes
 public/images/task_late.png                 | Bin 93 -> 0 bytes
 public/images/task_parent_end.png           | Bin 224 -> 0 bytes
 public/images/task_todo.png                 | Bin 93 -> 0 bytes
 public/images/version_marker.png            | Bin 174 -> 0 bytes
 public/javascripts/gantt.js                 | 161 +++++++++++++---
 public/stylesheets/application.css          |  85 +++++++--
 test/integration/routing/gantts_test.rb     |   2 +
 test/unit/lib/redmine/helpers/gantt_test.rb |  90 +++++----
 17 files changed, 459 insertions(+), 135 deletions(-)
 create mode 100644 app/views/gantts/change_duration.js.erb
 delete mode 100644 public/images/milestone_done.png
 delete mode 100644 public/images/milestone_late.png
 delete mode 100644 public/images/milestone_todo.png
 delete mode 100644 public/images/project_marker.png
 delete mode 100644 public/images/task_done.png
 delete mode 100644 public/images/task_late.png
 delete mode 100644 public/images/task_parent_end.png
 delete mode 100644 public/images/task_todo.png
 delete mode 100644 public/images/version_marker.png

diff --git a/app/controllers/gantts_controller.rb b/app/controllers/gantts_controller.rb
index 66c93b34a..ee1fab207 100644
--- a/app/controllers/gantts_controller.rb
+++ b/app/controllers/gantts_controller.rb
@@ -19,7 +19,7 @@
 
 class GanttsController < ApplicationController
   menu_item :gantt
-  before_action :find_optional_project
+  before_action :find_optional_project, :only => [:show]
 
   rescue_from Query::StatementInvalid, :with => :query_statement_invalid
 
@@ -55,4 +55,31 @@ class GanttsController < ApplicationController
       end
     end
   end
+
+  def change_duration
+    return render_error(:status => :unprocessable_entity) unless request.xhr?
+
+    @obj = Issue.find(params[:id])
+    raise Unauthorized unless @obj.visible?
+
+    ActiveRecord::Base.transaction do
+      @obj.init_journal(User.current)
+      @obj.safe_attributes = duration_params
+      unless @obj.save
+        render_403(:message => @obj.errors.full_messages.join)
+        raise ActiveRecord::Rollback
+      end
+      retrieve_query
+    rescue ActiveRecord::StaleObjectError
+      render_403(:message => :notice_issue_update_conflict)
+    rescue ActiveRecord::RecordNotFound
+      render_404
+    end
+  end
+
+  private
+
+  def duration_params
+    params.require(:change_duration).permit(:start_date, :due_date, :lock_version)
+  end
 end
diff --git a/app/views/gantts/change_duration.js.erb b/app/views/gantts/change_duration.js.erb
new file mode 100644
index 000000000..0bf8b23fd
--- /dev/null
+++ b/app/views/gantts/change_duration.js.erb
@@ -0,0 +1,30 @@
+var elm;
+<%
+gantt = Redmine::Helpers::Gantt.new(params)
+durations = gantt.duration(@obj, self, @query)
+durations.each do |duration|
+  elm_subject = duration[:elm_subject]
+  elm_todo = duration[:elm_todo]
+  todo_context = duration[:todo_context]
+  subject_content = duration[:subject_content]
+  columns = duration[:columns]
+  obj = duration[:obj]
+%>
+if ($('<%= raw(elm_subject) %>').length) {
+  $('<%= elm_todo %>').each(function (_, task) {
+    var el_parent = $(task).parent();
+    el_parent.html('<%= raw(todo_context) %>');
+    var number_of_rows = el_parent.attr('data-number-of-rows');
+    if (number_of_rows) {
+      el_parent.find('div[data-number-of-rows]').attr('data-number-of-rows', number_of_rows);
+    }
+  });
+  $('<%= raw(elm_subject) %>').replaceWith('<%= raw(subject_content) %>');
+  <% columns.each do |column| -%>
+  elm = $('div.gantt_selected_column_content #<%= column.name %>_issue_<%= obj.id %>');
+  if(elm.length) {
+    elm.html('<%= escape_javascript(column_content(column, obj)) %>');
+  }
+  <% end -%>
+}
+<% end -%>
diff --git a/config/routes.rb b/config/routes.rb
index d5296b3c3..2b3465447 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -63,6 +63,7 @@ Rails.application.routes.draw do
 
   get '/projects/:project_id/issues/gantt', :to => 'gantts#show', :as => 'project_gantt'
   get '/issues/gantt', :to => 'gantts#show'
+  put '/issues/gantt/:id/change_duration', :to => 'gantts#change_duration', :as => 'gantt_change_duration'
 
   get '/projects/:project_id/issues/calendar', :to => 'calendars#show', :as => 'project_calendar'
   get '/issues/calendar', :to => 'calendars#show'
diff --git a/lib/redmine/helpers/gantt.rb b/lib/redmine/helpers/gantt.rb
index 48cd93537..575c36b6c 100644
--- a/lib/redmine/helpers/gantt.rb
+++ b/lib/redmine/helpers/gantt.rb
@@ -629,6 +629,68 @@ module Redmine
         pdf.Output
       end
 
+      def duration(issue, view, query)
+        @view = view
+        @query = query
+        draw_obj = []
+
+        select_precedes = ->(issue) do
+          issue.relations_from.where(:relation_type => IssueRelation::TYPE_PRECEDES).map(&:issue_to).each do |follows|
+            next if draw_obj.include?(follows)
+
+            while follows
+              draw_obj.push(follows, follows.fixed_version, follows.project)
+              select_precedes.call(follows)
+              follows.children.each do |child|
+                draw_obj.push(child, child.fixed_version, child.project)
+                select_precedes.call(child)
+              end
+              follows = follows.parent
+            end
+          end
+        end
+
+        while issue
+          draw_obj.push(issue, issue.fixed_version, issue.project)
+          select_precedes.call(issue)
+          issue = issue.parent
+        end
+        draw_objs = draw_obj.compact.uniq
+        draw_objs.reject!{|obj| ![Project, Version, Issue].include?(obj.class)}
+
+        return_values = []
+        draw_objs.each do |obj|
+          @number_of_rows = 0
+          @lines = +''
+          render_object_row(obj, {format: :html, only: :lines, zoom: 2 ** @zoom, top: 0, top_increment: 20})
+          todo_content = Nokogiri::HTML.parse(@lines)
+          todo_context = todo_content.xpath(
+            "//div[contains(@class, 'task') and contains(@class, 'line')]/*"
+          ).to_s.tr("\n", '').gsub("'", "\\\\'")
+
+          klass_name = obj.class.name.underscore
+          elm_todo = "[id=task-todo-#{klass_name}-#{obj.id}]"
+          css_subject = 'span:not(.expander)'
+          elm_subject = "[id=#{klass_name}-#{obj.id}] > #{css_subject}"
+
+          subject_content = Nokogiri::HTML.parse(html_subject_content(obj))
+          subject_content = subject_content.css(css_subject).to_s.tr("\n", '').gsub("'", "\\\\'")
+
+          columns = []
+          case obj
+          when Issue
+            columns = query.columns
+          end
+          return_values << {:elm_subject => elm_subject,
+                            :elm_todo => elm_todo,
+                            :todo_context => todo_context,
+                            :subject_content => subject_content,
+                            :columns => columns,
+                            :obj => obj}
+        end
+        return_values
+      end
+
       private
 
       def coordinates(start_date, end_date, progress, zoom=nil)
@@ -774,6 +836,7 @@ module Redmine
           tag_options[:class] = "version-name"
           has_children = object.fixed_issues.exists?
         when Project
+          tag_options[:id] = "project-#{object.id}"
           tag_options[:class] = "project-name"
           has_children = object.issues.exists? || object.versions.exists?
         end
@@ -856,15 +919,21 @@ module Redmine
           end
         # Renders the task bar, with progress and late
         if coords[:bar_start] && coords[:bar_end]
-          width = coords[:bar_end] - coords[:bar_start] - 2
+          width = coords[:bar_end] - coords[:bar_start]
           style = +""
-          style << "top:#{params[:top]}px;"
           style << "left:#{coords[:bar_start]}px;"
           style << "width:#{width}px;"
-          html_id = "task-todo-issue-#{object.id}" if object.is_a?(Issue)
-          html_id = "task-todo-version-#{object.id}" if object.is_a?(Version)
+          html_id =
+            case object
+            when Project
+              "task-todo-project-#{object.id}"
+            when Version
+              "task-todo-version-#{object.id}"
+            when Issue
+              "task-todo-issue-#{object.id}"
+            end
           content_opt = {:style => style,
-                         :class => "#{css} task_todo",
+                         :class => "task_todo",
                          :id => html_id,
                          :data => {}}
           if object.is_a?(Issue)
@@ -872,32 +941,80 @@ module Redmine
             if rels.present?
               content_opt[:data] = {"rels" => rels.to_json}
             end
+            content_opt[:data].merge!({
+                                        :url_change_duration => Rails.application.routes.url_helpers.gantt_change_duration_path(
+                                          object
+                                        ),
+                                        :object => {
+                                          :start_date => object.start_date,
+                                          :due_date => object.due_date,
+                                          :lock_version => object.lock_version,
+                                        }.to_json,
+                                      })
           end
           content_opt[:data].merge!(data_options)
-          output << view.content_tag(:div, '&nbsp;'.html_safe, content_opt)
+          bar_contents = []
           if coords[:bar_late_end]
-            width = coords[:bar_late_end] - coords[:bar_start] - 2
+            width = coords[:bar_late_end] - coords[:bar_start]
             style = +""
-            style << "top:#{params[:top]}px;"
-            style << "left:#{coords[:bar_start]}px;"
             style << "width:#{width}px;"
-            output << view.content_tag(:div, '&nbsp;'.html_safe,
-                                       :style => style,
-                                       :class => "#{css} task_late",
-                                       :data => data_options)
+            bar_contents << view.content_tag(:div, '&nbsp;'.html_safe,
+                                             :style => style,
+                                             :class => "#{css} task_late",
+                                             :data => data_options)
           end
           if coords[:bar_progress_end]
-            width = coords[:bar_progress_end] - coords[:bar_start] - 2
+            width = coords[:bar_progress_end] - coords[:bar_start]
             style = +""
-            style << "top:#{params[:top]}px;"
-            style << "left:#{coords[:bar_start]}px;"
             style << "width:#{width}px;"
             html_id = "task-done-issue-#{object.id}" if object.is_a?(Issue)
             html_id = "task-done-version-#{object.id}" if object.is_a?(Version)
-            output << view.content_tag(:div, '&nbsp;'.html_safe,
+            bar_contents << view.content_tag(:div, '&nbsp;'.html_safe,
+                                             :style => style,
+                                             :class => "task_done",
+                                             :id => html_id,
+                                             :data => data_options)
+          end
+
+          # Renders the tooltip
+          if object.is_a?(Issue)
+            s = view.content_tag(:span,
+                                 view.render_issue_tooltip(object).html_safe,
+                                 :class => "tip")
+            s += view.content_tag(:input, nil, :type => 'checkbox', :name => 'ids[]', :value => object.id, :style => 'display:none;', :class => 'toggle-selection')
+            style = +""
+            style << "width:#{coords[:bar_end] - coords[:bar_start]}px;"
+            style << "height:12px;"
+            bar_contents << view.content_tag(:div, s.html_safe,
+                                             :style => style,
+                                             :class => "tooltip hascontextmenu",
+                                             :data => data_options)
+          end
+
+          # Renders the label on the right
+          if label
+            style = +""
+            style << "top:0px;"
+            style << "left:#{coords[:bar_end] - coords[:bar_start] + 8}px;"
+            style << "height:12px;"
+            bar_contents << view.content_tag(:div, label,
+                                             :style => style,
+                                             :class => "label",
+                                             :data => data_options)
+          end
+
+          bar_contents = bar_contents.join.presence
+          output << view.content_tag(:div, (bar_contents || '&nbsp;').html_safe, content_opt)
+        else
+          # Renders the label on the right
+          if label
+            style = +""
+            style << "top:1px;"
+            style << "left:#{(coords[:bar_end] || 0) + 8}px;"
+            output << view.content_tag(:div, label,
                                        :style => style,
-                                       :class => "#{css} task_done",
                                        :id => html_id,
+                                       :class => "label",
                                        :data => data_options)
           end
         end
@@ -905,55 +1022,26 @@ module Redmine
         if markers
           if coords[:start]
             style = +""
-            style << "top:#{params[:top]}px;"
             style << "left:#{coords[:start]}px;"
-            style << "width:15px;"
             output << view.content_tag(:div, '&nbsp;'.html_safe,
                                        :style => style,
-                                       :class => "#{css} marker starting",
+                                       :class => "marker starting",
                                        :data => data_options)
           end
           if coords[:end]
             style = +""
-            style << "top:#{params[:top]}px;"
             style << "left:#{coords[:end]}px;"
-            style << "width:15px;"
             output << view.content_tag(:div, '&nbsp;'.html_safe,
                                        :style => style,
-                                       :class => "#{css} marker ending",
+                                       :class => "marker ending",
                                        :data => data_options)
           end
         end
-        # Renders the label on the right
-        if label
-          style = +""
-          style << "top:#{params[:top]}px;"
-          style << "left:#{(coords[:bar_end] || 0) + 8}px;"
-          style << "width:15px;"
-          output << view.content_tag(:div, label,
-                                     :style => style,
-                                     :class => "#{css} label",
-                                     :data => data_options)
-        end
-        # Renders the tooltip
-        if object.is_a?(Issue) && coords[:bar_start] && coords[:bar_end]
-          s = view.content_tag(:span,
-                               view.render_issue_tooltip(object).html_safe,
-                               :class => "tip")
-          s += view.content_tag(:input, nil, :type => 'checkbox', :name => 'ids[]',
-                                :value => object.id, :style => 'display:none;',
-                                :class => 'toggle-selection')
-          style = +""
-          style << "position: absolute;"
-          style << "top:#{params[:top]}px;"
-          style << "left:#{coords[:bar_start]}px;"
-          style << "width:#{coords[:bar_end] - coords[:bar_start]}px;"
-          style << "height:12px;"
-          output << view.content_tag(:div, s.html_safe,
-                                     :style => style,
-                                     :class => "tooltip hascontextmenu",
-                                     :data => data_options)
-        end
+        output = view.content_tag(:div, output.html_safe,
+          :class => "#{css} line",
+          :style => "top:#{params[:top]}px;width:#{params[:g_width] - 1}px;",
+          :data => data_options
+        )
         @lines << output
         output
       end
diff --git a/public/images/milestone_done.png b/public/images/milestone_done.png
deleted file mode 100644
index 5fdcb415c0b002cb8853aea98619fb644e473097..0000000000000000000000000000000000000000
GIT binary patch
diff --git a/public/images/milestone_late.png b/public/images/milestone_late.png
deleted file mode 100644
index cf922e95450b05a61cd6d9060552e532739f8c64..0000000000000000000000000000000000000000
GIT binary patch
diff --git a/public/images/milestone_todo.png b/public/images/milestone_todo.png
deleted file mode 100644
index 4c051c857c8da1ef36f9fc9533f44791a30d782b..0000000000000000000000000000000000000000
GIT binary patch
diff --git a/public/images/project_marker.png b/public/images/project_marker.png
deleted file mode 100644
index 4124787d0e1f40ac2a5fee71c985128e3126b393..0000000000000000000000000000000000000000
GIT binary patch
diff --git a/public/images/task_done.png b/public/images/task_done.png
deleted file mode 100644
index 5fdcb415c0b002cb8853aea98619fb644e473097..0000000000000000000000000000000000000000
GIT binary patch
diff --git a/public/images/task_late.png b/public/images/task_late.png
deleted file mode 100644
index 71fd2d70b6040aef6c022b10a683e79ae4d6e464..0000000000000000000000000000000000000000
GIT binary patch
diff --git a/public/images/task_parent_end.png b/public/images/task_parent_end.png
deleted file mode 100644
index 9442b86a5f3392fa023d38a49c78c94a64337e39..0000000000000000000000000000000000000000
GIT binary patch
diff --git a/public/images/task_todo.png b/public/images/task_todo.png
deleted file mode 100644
index 632406ee1f01010f58c2747637d42c4fa8d70a7c..0000000000000000000000000000000000000000
GIT binary patch
diff --git a/public/images/version_marker.png b/public/images/version_marker.png
deleted file mode 100644
index 0368ca290a63076714047b1fc124f045c97ebe50..0000000000000000000000000000000000000000
GIT binary patch
diff --git a/public/javascripts/gantt.js b/public/javascripts/gantt.js
index 232110b22..2d6cb7e36 100644
--- a/public/javascripts/gantt.js
+++ b/public/javascripts/gantt.js
@@ -4,14 +4,12 @@
 var draw_gantt = null;
 var draw_top;
 var draw_right;
-var draw_left;
 
 var rels_stroke_width = 2;
 
 function setDrawArea() {
-  draw_top   = $("#gantt_draw_area").position().top;
+  draw_top   = $("#gantt_draw_area").offset().top;
   draw_right = $("#gantt_draw_area").width();
-  draw_left  = $("#gantt_area").scrollLeft();
 }
 
 function getRelationsArray() {
@@ -42,27 +40,27 @@ function drawRelations() {
       return;
     }
     var issue_height = issue_from.height();
-    var issue_from_top   = issue_from.position().top  + (issue_height / 2) - draw_top;
+    var issue_from_top   = issue_from.offset().top  + (issue_height / 2) - draw_top;
     var issue_from_right = issue_from.position().left + issue_from.width();
-    var issue_to_top   = issue_to.position().top  + (issue_height / 2) - draw_top;
+    var issue_to_top   = issue_to.offset().top  + (issue_height / 2) - draw_top;
     var issue_to_left  = issue_to.position().left;
     var color = issue_relation_type[element_issue["rel_type"]]["color"];
     var landscape_margin = issue_relation_type[element_issue["rel_type"]]["landscape_margin"];
     var issue_from_right_rel = issue_from_right + landscape_margin;
     var issue_to_left_rel    = issue_to_left    - landscape_margin;
-    draw_gantt.path(["M", issue_from_right + draw_left,     issue_from_top,
-                     "L", issue_from_right_rel + draw_left, issue_from_top])
+    draw_gantt.path(["M", issue_from_right,     issue_from_top,
+                     "L", issue_from_right_rel, issue_from_top])
                    .attr({stroke: color,
                           "stroke-width": rels_stroke_width
                           });
     if (issue_from_right_rel < issue_to_left_rel) {
-      draw_gantt.path(["M", issue_from_right_rel + draw_left, issue_from_top,
-                       "L", issue_from_right_rel + draw_left, issue_to_top])
+      draw_gantt.path(["M", issue_from_right_rel, issue_from_top,
+                       "L", issue_from_right_rel, issue_to_top])
                      .attr({stroke: color,
                           "stroke-width": rels_stroke_width
                           });
-      draw_gantt.path(["M", issue_from_right_rel + draw_left, issue_to_top,
-                       "L", issue_to_left + draw_left,        issue_to_top])
+      draw_gantt.path(["M", issue_from_right_rel, issue_to_top,
+                       "L", issue_to_left,        issue_to_top])
                      .attr({stroke: color,
                           "stroke-width": rels_stroke_width
                           });
@@ -70,28 +68,28 @@ function drawRelations() {
       var issue_middle_top = issue_to_top +
                                 (issue_height *
                                    ((issue_from_top > issue_to_top) ? 1 : -1));
-      draw_gantt.path(["M", issue_from_right_rel + draw_left, issue_from_top,
-                       "L", issue_from_right_rel + draw_left, issue_middle_top])
+      draw_gantt.path(["M", issue_from_right_rel, issue_from_top,
+                       "L", issue_from_right_rel, issue_middle_top])
                      .attr({stroke: color,
                           "stroke-width": rels_stroke_width
                           });
-      draw_gantt.path(["M", issue_from_right_rel + draw_left, issue_middle_top,
-                       "L", issue_to_left_rel + draw_left,    issue_middle_top])
+      draw_gantt.path(["M", issue_from_right_rel, issue_middle_top,
+                       "L", issue_to_left_rel,    issue_middle_top])
                      .attr({stroke: color,
                           "stroke-width": rels_stroke_width
                           });
-      draw_gantt.path(["M", issue_to_left_rel + draw_left, issue_middle_top,
-                       "L", issue_to_left_rel + draw_left, issue_to_top])
+      draw_gantt.path(["M", issue_to_left_rel, issue_middle_top,
+                       "L", issue_to_left_rel, issue_to_top])
                      .attr({stroke: color,
                           "stroke-width": rels_stroke_width
                           });
-      draw_gantt.path(["M", issue_to_left_rel + draw_left, issue_to_top,
-                       "L", issue_to_left + draw_left,     issue_to_top])
+      draw_gantt.path(["M", issue_to_left_rel, issue_to_top,
+                       "L", issue_to_left,     issue_to_top])
                      .attr({stroke: color,
                           "stroke-width": rels_stroke_width
                           });
     }
-    draw_gantt.path(["M", issue_to_left + draw_left, issue_to_top,
+    draw_gantt.path(["M", issue_to_left, issue_to_top,
                      "l", -4 * rels_stroke_width, -2 * rels_stroke_width,
                      "l", 0, 4 * rels_stroke_width, "z"])
                    .attr({stroke: "none",
@@ -104,11 +102,11 @@ function drawRelations() {
 
 function getProgressLinesArray() {
   var arr = new Array();
-  var today_left = $('#today_line').position().left;
+  var today_left = $('#today_line').position().left + $("#gantt_area").scrollLeft();
   arr.push({left: today_left, top: 0});
   $.each($('div.issue-subject, div.version-name'), function(index, element) {
     if(!$(element).is(':visible')) return true;
-    var t = $(element).position().top - draw_top ;
+    var t = $(element).offset().top - draw_top ;
     var h = ($(element).height() / 9);
     var element_top_upper  = t - h;
     var element_top_center = t + (h * 3);
@@ -125,8 +123,11 @@ function getProgressLinesArray() {
         arr.push({left: draw_right, top: element_top_upper, is_right_edge: true});
         arr.push({left: draw_right, top: element_top_lower, is_right_edge: true, none_stroke: true});
       } else if (issue_done.length > 0) {
-        var done_left = issue_done.first().position().left +
-                           issue_done.first().width();
+        var done_left = today_left;
+        var issue_todo = $("#task-todo-" + $(element).attr("id"));
+        if (issue_todo.length > 0){
+          done_left = issue_done.first().position().left;
+        }
         arr.push({left: done_left, top: element_top_center});
       } else if (is_behind_start) {
         arr.push({left: 0 , top: element_top_upper, is_left_edge: true});
@@ -145,6 +146,7 @@ function getProgressLinesArray() {
 }
 
 function drawGanttProgressLines() {
+  if (!$("#today_line").length) return;
   var arr = getProgressLinesArray();
   var color = $("#today_line")
                     .css("border-left-color");
@@ -154,8 +156,8 @@ function drawGanttProgressLines() {
         (!("is_right_edge" in arr[i - 1] && "is_right_edge" in arr[i]) &&
          !("is_left_edge"  in arr[i - 1] && "is_left_edge"  in arr[i]))
         ) {
-      var x1 = (arr[i - 1].left == 0) ? 0 : arr[i - 1].left + draw_left;
-      var x2 = (arr[i].left == 0)     ? 0 : arr[i].left     + draw_left;
+      var x1 = (arr[i - 1].left == 0) ? 0 : arr[i - 1].left;
+      var x2 = (arr[i].left == 0)     ? 0 : arr[i].left;
       draw_gantt.path(["M", x1, arr[i - 1].top,
                        "L", x2, arr[i].top])
                    .attr({stroke: color, "stroke-width": 2});
@@ -303,3 +305,110 @@ function disable_unavailable_columns(unavailable_columns) {
     $('#available_c, #selected_c').children("[value='" + value + "']").prop('disabled', true);
   });
 }
+
+initGanttDnD = function() {
+  var grid_x = 0;
+  if ($('#zoom').length) {
+    switch(parseInt($('#zoom').val())) {
+    case 4:
+      grid_x = 16;
+      break;
+    case 3:
+      grid_x = 8;
+      break;
+    }
+  }
+  if (grid_x > 0) {
+    $('.leaf .task_todo').draggable({
+      containment: 'parent',
+      axis: 'x',
+      grid: [grid_x, 0],
+      opacity: 0.5,
+      cursor: 'move',
+      revertDuration: 100,
+      start: function (_, ui) {
+        var helper = ui.helper[0];
+        helper.startLeft = ui.position.left;
+      },
+    });
+
+    $('.task.line').droppable({
+      accept: '.leaf .task_todo',
+      drop: function (event, ui) {
+        var target = $(ui.draggable);
+        var url = target.attr('data-url-change-duration');
+        var object = JSON.parse(target.attr('data-object'));
+        var startLeft = target[0].startLeft;
+        var relative_days = Math.floor((ui.position.left - startLeft) / grid_x);
+        if (relative_days == 0) {
+          return;
+        }
+        var start_date = new Date(object.start_date);
+        start_date.setDate(start_date.getDate() + relative_days);
+        start_date =
+          [
+            start_date.getFullYear(),
+            ('0' + (start_date.getMonth() + 1)).slice(-2),
+            ('0' + start_date.getDate()).slice(-2)
+          ].join('-');
+        var due_date = null;
+        if (object.due_date !== null) {
+          due_date = new Date(object.due_date);
+          due_date.setDate(due_date.getDate() + relative_days);
+          due_date = 
+            [
+              due_date.getFullYear(),
+              ('0' + (due_date.getMonth() + 1)).slice(-2),
+              ('0' + due_date.getDate()).slice(-2)
+            ].join('-');
+        }
+
+        $('#selected_c option:not(:disabled)').prop('selected', true);
+        var form = $('#query_from').serializeArray();
+        var json_param = {};
+        form.forEach(function (data) {
+          var key = data.name;
+          var value = data.value;
+          if (/\[\]$/.test(key)) {
+            if (!json_param.hasOwnProperty(key)) {
+              json_param[key] = [];
+            }
+            json_param[key].push(value);
+          } else {
+            json_param[key] = value;
+          }
+        });
+        $('#selected_c option:not(:disabled)').prop('selected', false);
+        Object.assign(json_param, {
+          change_duration: {
+            start_date: start_date,
+            due_date: due_date,
+            lock_version: object.lock_version,
+          },
+        });
+
+        $.ajax({
+          type: 'PUT',
+          url: url,
+          data: json_param,
+          dataType: 'script',
+        }).done(function (_) {
+          drawGanttHandler();
+          initGanttDnD();
+        }).fail(function (jqXHR) {
+          var contents = $('<div>' + jqXHR.responseText + '</div>');
+          var error_message = contents.find('p#errorExplanation');
+          if (error_message.length) {
+            $('div#content h2:first-of-type').after(error_message);
+            $('p#errorExplanation').hide('fade', {}, 3000, function() {
+              $(this).remove();
+            });
+          }
+          ui.draggable.animate({left: ui.draggable[0].startLeft}, 'fast');
+        });
+      }
+    });
+  }
+};
+
+$(document).ready(initGanttDnD);
\ No newline at end of file
diff --git a/public/stylesheets/application.css b/public/stylesheets/application.css
index b8212c068..6b5426288 100644
--- a/public/stylesheets/application.css
+++ b/public/stylesheets/application.css
@@ -1496,7 +1496,7 @@ td.gantt_selected_column .gantt_hdr,.gantt_selected_column_container {
 
 .task {
   position: absolute;
-  height:8px;
+  height:10px;
   font-size:0.8em;
   color:#888;
   padding:0;
@@ -1505,27 +1505,76 @@ td.gantt_selected_column .gantt_hdr,.gantt_selected_column_container {
   white-space:nowrap;
 }
 
-.task.label {width:100%;}
-.task.label.project, .task.label.version { font-weight: bold; }
+.task.line { left: 0; }
+.task div.tooltip:hover span.tip { font-size: inherit; }
+.task .task_todo .label { font-size: inherit; }
+.task.project .task_todo .label { margin-top: -4px; }
+.task.version .task_todo .label { margin-top: -3px; }
 
-.task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
-.task_done { background:#00c600 url(../images/task_done.png); border: 1px solid #00c600; }
-.task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
+.task .label { position: absolute; width: auto; }
+.task.project .label, .task.version .label { font-weight: bold; }
 
-.task_todo.parent { background: #888; border: 1px solid #888; height: 3px;}
-.task_late.parent, .task_done.parent { height: 3px;}
-.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;}
-.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;}
+.task_late { position: absolute; height: inherit; background:#f66; }
+.task_done { position: absolute; height: inherit; background:#00c600; }
+.task_todo { position: absolute; height: inherit; background:#aaa; }
 
-.version.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
-.version.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
-.version.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
-.version.marker { background-image:url(../images/version_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
+.parent .task_todo { background: #888; height: 5px; }
+.parent .task_late, .parent .task_done { height: 5px; }
+.parent .marker {
+  background: #888;
+  display: inline-block;
+  position: absolute;
+  width: 8px;
+  height: 6px;
+  margin-left: -5px;
+  margin-bottom: -4px;
+}
+
+.version .task_late { background:#f66; height: 4px; }
+.version .task_done { background:#00c600; height: 4px; }
+.version .task_todo { background:#aaa; height: 4px; margin-top: 3px; }
+.verison .marker {
+  width: 0;
+  height: 0;
+  border: 5px solid trasnparent;
+  border-bottom-color: black;
+  position: absolute;
+  margin-top: -5px;
+  margin-left: -6px;
+}
+.version .marker:after {
+  content: '';
+  position: absolute;
+  left: -5px;
+  top: 5px;
+  width: 0;
+  height: 0;
+  border: 5px solid transparent;
+  border-top-color: black;
+}
 
-.project.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
-.project.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
-.project.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
-.project.marker { background-image:url(../images/project_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
+.project .task_late { background:#f66; height: 2px; }
+.project .task_done { background:#00c600; height: 2px; }
+.project .task_todo { background:#aaa; height: 2px; margin-top: 4px; }
+.project .marker {
+  width: 0;
+  height: 0;
+  border: 5px solid transparent;
+  border-bottom-color: blue;
+  position: absolute;
+  margin-top: -5px;
+  margin-left: -6px;
+}
+.project .marker:after {
+  content: '';
+  position: absolute;
+  left: -5px;
+  top: 5px;
+  width: 0;
+  height: 0;
+  border: 5px solid transparent;
+  border-top-color: blue;
+}
 
 .version-behind-schedule a, .issue-behind-schedule a {color: #f66914;}
 .version-overdue a, .issue-overdue a, .project-overdue a {color: #f00;}
diff --git a/test/integration/routing/gantts_test.rb b/test/integration/routing/gantts_test.rb
index 3fb4623de..87c26f1e4 100644
--- a/test/integration/routing/gantts_test.rb
+++ b/test/integration/routing/gantts_test.rb
@@ -26,5 +26,7 @@ class RoutingGanttsTest < Redmine::RoutingTest
 
     should_route 'GET /projects/foo/issues/gantt' => 'gantts#show', :project_id => 'foo'
     should_route 'GET /projects/foo/issues/gantt.pdf' => 'gantts#show', :project_id => 'foo', :format => 'pdf'
+
+    should_route 'PUT /issues/gantt/123/change_duration' => 'gantts#change_duration', :id => '123'
   end
 end
diff --git a/test/unit/lib/redmine/helpers/gantt_test.rb b/test/unit/lib/redmine/helpers/gantt_test.rb
index 25aebbcaf..4ef006a57 100644
--- a/test/unit/lib/redmine/helpers/gantt_test.rb
+++ b/test/unit/lib/redmine/helpers/gantt_test.rb
@@ -235,19 +235,28 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest
     @project.issues << @issue
     @output_buffer = @gantt.lines
 
-    assert_select "div.project.task_todo"
-    assert_select "div.project.starting"
-    assert_select "div.project.ending"
-    assert_select "div.label.project", /#{@project.name}/
+    assert_select "div.task.project" do
+      assert_select "> div.task_todo" do
+        assert_select "> div.label", /#{@project.name}/
+      end
+      assert_select "> div.starting"
+      assert_select "> div.ending"
+    end
 
-    assert_select "div.version.task_todo"
-    assert_select "div.version.starting"
-    assert_select "div.version.ending"
-    assert_select "div.label.version", /#{@version.name}/
+    assert_select "div.task.version" do
+      assert_select "> div.task_todo" do
+        assert_select "> div.label", /#{@version.name}/
+      end
+      assert_select "> div.starting"
+      assert_select "> div.ending"
+    end
 
-    assert_select "div.task_todo"
-    assert_select "div.task.label", /#{@issue.done_ratio}/
-    assert_select "div.tooltip", /#{@issue.subject}/
+    assert_select "div.task" do
+      assert_select "> div.task_todo" do
+        assert_select "> div.label", /#{@issue.done_ratio}/
+        assert_select "> div.tooltip", /#{@issue.subject}/
+      end
+    end
   end
 
   test "#selected_column_content" do
@@ -331,7 +340,9 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest
     @project.stubs(:start_date).returns(today - 7)
     @project.stubs(:due_date).returns(today + 7)
     @output_buffer = @gantt.line_for_project(@project, :format => :html)
-    assert_select "div.project.label", :text => @project.name
+    assert_select "div.task.project > div.task_todo" do
+      assert_select "> div.label", :text => @project.name
+    end
   end
 
   test "#line_for_version" do
@@ -341,17 +352,19 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest
     version.stubs(:due_date).returns(today + 7)
     version.stubs(:visible_fixed_issues => stub(:completed_percent => 30))
     @output_buffer = @gantt.line_for_version(version, :format => :html)
-    assert_select "div.version.label", :text => /Foo/
-    assert_select "div.version.label", :text => /30%/
+    assert_select "div.task.version > div.task_todo" do
+      assert_select "> div.label", :text => 'Foo 30%'
+    end
   end
 
   test "#line_for_issue" do
     create_gantt
     issue = Issue.generate!(:project => @project, :start_date => today - 7, :due_date => today + 7, :done_ratio => 30)
     @output_buffer = @gantt.line_for_issue(issue, :format => :html)
-    assert_select "div.task.label", :text => /#{issue.status.name}/
-    assert_select "div.task.label", :text => /30%/
-    assert_select "div.tooltip", /#{issue.subject}/
+    assert_select "div.task_todo" do
+      assert_select "> div.label", :text => "#{issue.status.name} 30%"
+      assert_select "> div.tooltip", :text => /#{issue.subject}/
+    end
   end
 
   test "#line todo line should start from the starting point on the left" do
@@ -365,8 +378,7 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest
     [gantt_start - 1, gantt_start].each do |start_date|
       @output_buffer = @gantt.line(start_date, gantt_start, 30, false, 'line', :format => :html, :zoom => 4)
       # the leftmost date (Date.today - 14 days)
-      assert_select 'div.task_todo[style*="left:0px"]', 1, @output_buffer
-      assert_select 'div.task_todo[style*="width:2px"]', 1, @output_buffer
+      assert_select 'div.task_todo[style*="left:0px"][style*="width:4px"]', 1, @output_buffer
     end
   end
 
@@ -375,74 +387,80 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest
     [gantt_end, gantt_end + 1].each do |end_date|
       @output_buffer = @gantt.line(gantt_end, end_date, 30, false, 'line', :format => :html, :zoom => 4)
       # the rightmost date (Date.today + 14 days)
-      assert_select 'div.task_todo[style*="left:112px"]', 1, @output_buffer
-      assert_select 'div.task_todo[style*="width:2px"]', 1, @output_buffer
+      assert_select 'div.task_todo[style*="left:112px"][style*="width:4px"]', 1, @output_buffer
     end
   end
 
   test "#line todo line should be the total width" do
     create_gantt
     @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4)
-    assert_select 'div.task_todo[style*="width:58px"]', 1
+    assert_select 'div.task_todo[style*="width:60px"]', 1
   end
 
   test "#line late line should start from the starting point on the left" do
     create_gantt
     @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4)
-    assert_select 'div.task_late[style*="left:28px"]', 1
+    assert_select 'div.task_todo[style*="left:28px"]' do
+      assert_select '> div.task_late', 1
+    end
   end
 
   test "#line late line should be the total delayed width" do
     create_gantt
     @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4)
-    assert_select 'div.task_late[style*="width:30px"]', 1
+    assert_select 'div.task_late[style*="width:32px"]', 1
   end
 
   test "#line late line should be the same width as task_todo if start date and end date are the same day" do
     create_gantt
     @output_buffer = @gantt.line(today - 7, today - 7, 0, false, 'line', :format => :html, :zoom => 4)
-    assert_select 'div.task_late[style*="width:2px"]', 1
-    assert_select 'div.task_todo[style*="width:2px"]', 1
+    assert_select 'div.task_todo[style*="width:4px"]' do
+      assert_select '> div.task_late[style*="width:4px"]', 1
+    end
   end
 
   test "#line late line should be the same width as task_todo if start date and today are the same day" do
     create_gantt
     @output_buffer = @gantt.line(today, today, 0, false, 'line', :format => :html, :zoom => 4)
-    assert_select 'div.task_late[style*="width:2px"]', 1
-    assert_select 'div.task_todo[style*="width:2px"]', 1
+    assert_select 'div.task_todo[style*="width:4px"]' do
+      assert_select '> div.task_late[style*="width:4px"]', 1
+    end
   end
 
   test "#line done line should start from the starting point on the left" do
     create_gantt
     @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4)
-    assert_select 'div.task_done[style*="left:28px"]', 1
+    assert_select 'div.task_todo[style*="left:28px"]' do
+      assert_select '> div.task_done', 1
+    end
   end
 
   test "#line done line should be the width for the done ratio" do
     create_gantt
     @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4)
-    # 15 days * 4 px * 30% - 2 px for borders = 16 px
-    assert_select 'div.task_done[style*="width:16px"]', 1
+    # 15 days * 4 px * 30% = 18 px
+    assert_select 'div.task_done[style*="width:18px"]', 1
   end
 
   test "#line done line should be the total width for 100% done ratio" do
     create_gantt
     @output_buffer = @gantt.line(today - 7, today + 7, 100, false, 'line', :format => :html, :zoom => 4)
-    # 15 days * 4 px - 2 px for borders = 58 px
-    assert_select 'div.task_done[style*="width:58px"]', 1
+    # 15 days * 4 px = 60 px
+    assert_select 'div.task_done[style*="width:60px"]', 1
   end
 
   test "#line done line should be the total width for 100% done ratio with same start and end dates" do
     create_gantt
     @output_buffer = @gantt.line(today + 7, today + 7, 100, false, 'line', :format => :html, :zoom => 4)
-    assert_select 'div.task_done[style*="width:2px"]', 1
+    assert_select 'div.task_done[style*="width:4px"]', 1
   end
 
   test "#line done line should not be the total done width if the gantt starts after start date" do
     create_gantt
     @output_buffer = @gantt.line(today - 16, today - 2, 30, false, 'line', :format => :html, :zoom => 4)
-    assert_select 'div.task_done[style*="left:0px"]', 1
-    assert_select 'div.task_done[style*="width:8px"]', 1
+    assert_select 'div.task_todo[style*="left:0px"]' do
+      assert_select '> div.task_done[style*="width:10px"]', 1
+    end
   end
 
   test "#line starting marker should appear at the start date" do
-- 
2.34.1

