Feature #2024 » 0001-refs-2024-integrate-gantt-change_duration.patch
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 = duration_params |
|
68 |
unless @obj.save |
|
69 |
render_403(:message => @obj.errors.full_messages.join) |
|
70 |
raise ActiveRecord::Rollback |
|
71 |
end |
|
72 |
retrieve_query |
|
73 |
rescue ActiveRecord::StaleObjectError |
|
74 |
render_403(:message => :notice_issue_update_conflict) |
|
75 |
rescue ActiveRecord::RecordNotFound |
|
76 |
render_404 |
|
77 |
end |
|
78 |
end |
|
79 | ||
80 |
private |
|
81 | ||
82 |
def duration_params |
|
83 |
params.require(:change_duration).permit(:start_date, :due_date, :lock_version) |
|
84 |
end |
|
58 | 85 |
end |
app/views/gantts/change_duration.js.erb | ||
---|---|---|
1 |
var elm; |
|
2 |
<% |
|
3 |
gantt = Redmine::Helpers::Gantt.new(params) |
|
4 |
durations = gantt.duration(@obj, self, @query) |
|
5 |
durations.each do |duration| |
|
6 |
elm_subject = duration[:elm_subject] |
|
7 |
elm_todo = duration[:elm_todo] |
|
8 |
todo_context = duration[:todo_context] |
|
9 |
subject_content = duration[:subject_content] |
|
10 |
columns = duration[:columns] |
|
11 |
obj = duration[:obj] |
|
12 |
%> |
|
13 |
if ($('<%= raw(elm_subject) %>').length) { |
|
14 |
$('<%= elm_todo %>').each(function (_, task) { |
|
15 |
var el_parent = $(task).parent(); |
|
16 |
el_parent.html('<%= raw(todo_context) %>'); |
|
17 |
var number_of_rows = el_parent.attr('data-number-of-rows'); |
|
18 |
if (number_of_rows) { |
|
19 |
el_parent.find('div[data-number-of-rows]').attr('data-number-of-rows', number_of_rows); |
|
20 |
} |
|
21 |
}); |
|
22 |
$('<%= raw(elm_subject) %>').replaceWith('<%= raw(subject_content) %>'); |
|
23 |
<% columns.each do |column| -%> |
|
24 |
elm = $('div.gantt_selected_column_content #<%= column.name %>_issue_<%= obj.id %>'); |
|
25 |
if(elm.length) { |
|
26 |
elm.html('<%= escape_javascript(column_content(column, obj)) %>'); |
|
27 |
} |
|
28 |
<% end -%> |
|
29 |
} |
|
30 |
<% end -%> |
config/routes.rb | ||
---|---|---|
63 | 63 | |
64 | 64 |
get '/projects/:project_id/issues/gantt', :to => 'gantts#show', :as => 'project_gantt' |
65 | 65 |
get '/issues/gantt', :to => 'gantts#show' |
66 |
put '/issues/gantt/:id/change_duration', :to => 'gantts#change_duration', :as => 'gantt_change_duration' |
|
66 | 67 | |
67 | 68 |
get '/projects/:project_id/issues/calendar', :to => 'calendars#show', :as => 'project_calendar' |
68 | 69 |
get '/issues/calendar', :to => 'calendars#show' |
lib/redmine/helpers/gantt.rb | ||
---|---|---|
629 | 629 |
pdf.Output |
630 | 630 |
end |
631 | 631 | |
632 |
def duration(issue, view, query) |
|
633 |
@view = view |
|
634 |
@query = query |
|
635 |
draw_obj = [] |
|
636 | ||
637 |
select_precedes = ->(issue) do |
|
638 |
issue.relations_from.where(:relation_type => IssueRelation::TYPE_PRECEDES).map(&:issue_to).each do |follows| |
|
639 |
next if draw_obj.include?(follows) |
|
640 | ||
641 |
while follows |
|
642 |
draw_obj.push(follows, follows.fixed_version, follows.project) |
|
643 |
select_precedes.call(follows) |
|
644 |
follows.children.each do |child| |
|
645 |
draw_obj.push(child, child.fixed_version, child.project) |
|
646 |
select_precedes.call(child) |
|
647 |
end |
|
648 |
follows = follows.parent |
|
649 |
end |
|
650 |
end |
|
651 |
end |
|
652 | ||
653 |
while issue |
|
654 |
draw_obj.push(issue, issue.fixed_version, issue.project) |
|
655 |
select_precedes.call(issue) |
|
656 |
issue = issue.parent |
|
657 |
end |
|
658 |
draw_objs = draw_obj.compact.uniq |
|
659 |
draw_objs.reject!{|obj| ![Project, Version, Issue].include?(obj.class)} |
|
660 | ||
661 |
return_values = [] |
|
662 |
draw_objs.each do |obj| |
|
663 |
@number_of_rows = 0 |
|
664 |
@lines = +'' |
|
665 |
render_object_row(obj, {format: :html, only: :lines, zoom: 2 ** @zoom, top: 0, top_increment: 20}) |
|
666 |
todo_content = Nokogiri::HTML.parse(@lines) |
|
667 |
todo_context = todo_content.xpath( |
|
668 |
"//div[contains(@class, 'task') and contains(@class, 'line')]/*" |
|
669 |
).to_s.tr("\n", '').gsub("'", "\\\\'") |
|
670 | ||
671 |
klass_name = obj.class.name.underscore |
|
672 |
elm_todo = "[id=task-todo-#{klass_name}-#{obj.id}]" |
|
673 |
css_subject = 'span:not(.expander)' |
|
674 |
elm_subject = "[id=#{klass_name}-#{obj.id}] > #{css_subject}" |
|
675 | ||
676 |
subject_content = Nokogiri::HTML.parse(html_subject_content(obj)) |
|
677 |
subject_content = subject_content.css(css_subject).to_s.tr("\n", '').gsub("'", "\\\\'") |
|
678 | ||
679 |
columns = [] |
|
680 |
case obj |
|
681 |
when Issue |
|
682 |
columns = query.columns |
|
683 |
end |
|
684 |
return_values << {:elm_subject => elm_subject, |
|
685 |
:elm_todo => elm_todo, |
|
686 |
:todo_context => todo_context, |
|
687 |
:subject_content => subject_content, |
|
688 |
:columns => columns, |
|
689 |
:obj => obj} |
|
690 |
end |
|
691 |
return_values |
|
692 |
end |
|
693 | ||
632 | 694 |
private |
633 | 695 | |
634 | 696 |
def coordinates(start_date, end_date, progress, zoom=nil) |
... | ... | |
774 | 836 |
tag_options[:class] = "version-name" |
775 | 837 |
has_children = object.fixed_issues.exists? |
776 | 838 |
when Project |
839 |
tag_options[:id] = "project-#{object.id}" |
|
777 | 840 |
tag_options[:class] = "project-name" |
778 | 841 |
has_children = object.issues.exists? || object.versions.exists? |
779 | 842 |
end |
... | ... | |
856 | 919 |
end |
857 | 920 |
# Renders the task bar, with progress and late |
858 | 921 |
if coords[:bar_start] && coords[:bar_end] |
859 |
width = coords[:bar_end] - coords[:bar_start] - 2
|
|
922 |
width = coords[:bar_end] - coords[:bar_start] |
|
860 | 923 |
style = +"" |
861 |
style << "top:#{params[:top]}px;" |
|
862 | 924 |
style << "left:#{coords[:bar_start]}px;" |
863 | 925 |
style << "width:#{width}px;" |
864 |
html_id = "task-todo-issue-#{object.id}" if object.is_a?(Issue) |
|
865 |
html_id = "task-todo-version-#{object.id}" if object.is_a?(Version) |
|
926 |
html_id = |
|
927 |
case object |
|
928 |
when Project |
|
929 |
"task-todo-project-#{object.id}" |
|
930 |
when Version |
|
931 |
"task-todo-version-#{object.id}" |
|
932 |
when Issue |
|
933 |
"task-todo-issue-#{object.id}" |
|
934 |
end |
|
866 | 935 |
content_opt = {:style => style, |
867 |
:class => "#{css} task_todo",
|
|
936 |
:class => "task_todo", |
|
868 | 937 |
:id => html_id, |
869 | 938 |
:data => {}} |
870 | 939 |
if object.is_a?(Issue) |
... | ... | |
872 | 941 |
if rels.present? |
873 | 942 |
content_opt[:data] = {"rels" => rels.to_json} |
874 | 943 |
end |
944 |
content_opt[:data].merge!({ |
|
945 |
:url_change_duration => Rails.application.routes.url_helpers.gantt_change_duration_path( |
|
946 |
object |
|
947 |
), |
|
948 |
:object => { |
|
949 |
:start_date => object.start_date, |
|
950 |
:due_date => object.due_date, |
|
951 |
:lock_version => object.lock_version, |
|
952 |
}.to_json, |
|
953 |
}) |
|
875 | 954 |
end |
876 | 955 |
content_opt[:data].merge!(data_options) |
877 |
output << view.content_tag(:div, ' '.html_safe, content_opt)
|
|
956 |
bar_contents = []
|
|
878 | 957 |
if coords[:bar_late_end] |
879 |
width = coords[:bar_late_end] - coords[:bar_start] - 2
|
|
958 |
width = coords[:bar_late_end] - coords[:bar_start] |
|
880 | 959 |
style = +"" |
881 |
style << "top:#{params[:top]}px;" |
|
882 |
style << "left:#{coords[:bar_start]}px;" |
|
883 | 960 |
style << "width:#{width}px;" |
884 |
output << view.content_tag(:div, ' '.html_safe,
|
|
885 |
:style => style, |
|
886 |
:class => "#{css} task_late", |
|
887 |
:data => data_options) |
|
961 |
bar_contents << view.content_tag(:div, ' '.html_safe,
|
|
962 |
:style => style,
|
|
963 |
:class => "#{css} task_late",
|
|
964 |
:data => data_options)
|
|
888 | 965 |
end |
889 | 966 |
if coords[:bar_progress_end] |
890 |
width = coords[:bar_progress_end] - coords[:bar_start] - 2
|
|
967 |
width = coords[:bar_progress_end] - coords[:bar_start] |
|
891 | 968 |
style = +"" |
892 |
style << "top:#{params[:top]}px;" |
|
893 |
style << "left:#{coords[:bar_start]}px;" |
|
894 | 969 |
style << "width:#{width}px;" |
895 | 970 |
html_id = "task-done-issue-#{object.id}" if object.is_a?(Issue) |
896 | 971 |
html_id = "task-done-version-#{object.id}" if object.is_a?(Version) |
897 |
output << view.content_tag(:div, ' '.html_safe, |
|
972 |
bar_contents << view.content_tag(:div, ' '.html_safe, |
|
973 |
:style => style, |
|
974 |
:class => "task_done", |
|
975 |
:id => html_id, |
|
976 |
:data => data_options) |
|
977 |
end |
|
978 | ||
979 |
# Renders the tooltip |
|
980 |
if object.is_a?(Issue) |
|
981 |
s = view.content_tag(:span, |
|
982 |
view.render_issue_tooltip(object).html_safe, |
|
983 |
:class => "tip") |
|
984 |
s += view.content_tag(:input, nil, :type => 'checkbox', :name => 'ids[]', :value => object.id, :style => 'display:none;', :class => 'toggle-selection') |
|
985 |
style = +"" |
|
986 |
style << "width:#{coords[:bar_end] - coords[:bar_start]}px;" |
|
987 |
style << "height:12px;" |
|
988 |
bar_contents << view.content_tag(:div, s.html_safe, |
|
989 |
:style => style, |
|
990 |
:class => "tooltip hascontextmenu", |
|
991 |
:data => data_options) |
|
992 |
end |
|
993 | ||
994 |
# Renders the label on the right |
|
995 |
if label |
|
996 |
style = +"" |
|
997 |
style << "top:0px;" |
|
998 |
style << "left:#{coords[:bar_end] - coords[:bar_start] + 8}px;" |
|
999 |
style << "height:12px;" |
|
1000 |
bar_contents << view.content_tag(:div, label, |
|
1001 |
:style => style, |
|
1002 |
:class => "label", |
|
1003 |
:data => data_options) |
|
1004 |
end |
|
1005 | ||
1006 |
bar_contents = bar_contents.join.presence |
|
1007 |
output << view.content_tag(:div, (bar_contents || ' ').html_safe, content_opt) |
|
1008 |
else |
|
1009 |
# Renders the label on the right |
|
1010 |
if label |
|
1011 |
style = +"" |
|
1012 |
style << "top:1px;" |
|
1013 |
style << "left:#{(coords[:bar_end] || 0) + 8}px;" |
|
1014 |
output << view.content_tag(:div, label, |
|
898 | 1015 |
:style => style, |
899 |
:class => "#{css} task_done", |
|
900 | 1016 |
:id => html_id, |
1017 |
:class => "label", |
|
901 | 1018 |
:data => data_options) |
902 | 1019 |
end |
903 | 1020 |
end |
... | ... | |
905 | 1022 |
if markers |
906 | 1023 |
if coords[:start] |
907 | 1024 |
style = +"" |
908 |
style << "top:#{params[:top]}px;" |
|
909 | 1025 |
style << "left:#{coords[:start]}px;" |
910 |
style << "width:15px;" |
|
911 | 1026 |
output << view.content_tag(:div, ' '.html_safe, |
912 | 1027 |
:style => style, |
913 |
:class => "#{css} marker starting",
|
|
1028 |
:class => "marker starting", |
|
914 | 1029 |
:data => data_options) |
915 | 1030 |
end |
916 | 1031 |
if coords[:end] |
917 | 1032 |
style = +"" |
918 |
style << "top:#{params[:top]}px;" |
|
919 | 1033 |
style << "left:#{coords[:end]}px;" |
920 |
style << "width:15px;" |
|
921 | 1034 |
output << view.content_tag(:div, ' '.html_safe, |
922 | 1035 |
:style => style, |
923 |
:class => "#{css} marker ending",
|
|
1036 |
:class => "marker ending", |
|
924 | 1037 |
:data => data_options) |
925 | 1038 |
end |
926 | 1039 |
end |
927 |
# Renders the label on the right |
|
928 |
if label |
|
929 |
style = +"" |
|
930 |
style << "top:#{params[:top]}px;" |
|
931 |
style << "left:#{(coords[:bar_end] || 0) + 8}px;" |
|
932 |
style << "width:15px;" |
|
933 |
output << view.content_tag(:div, label, |
|
934 |
:style => style, |
|
935 |
:class => "#{css} label", |
|
936 |
:data => data_options) |
|
937 |
end |
|
938 |
# Renders the tooltip |
|
939 |
if object.is_a?(Issue) && coords[:bar_start] && coords[:bar_end] |
|
940 |
s = view.content_tag(:span, |
|
941 |
view.render_issue_tooltip(object).html_safe, |
|
942 |
:class => "tip") |
|
943 |
s += view.content_tag(:input, nil, :type => 'checkbox', :name => 'ids[]', |
|
944 |
:value => object.id, :style => 'display:none;', |
|
945 |
:class => 'toggle-selection') |
|
946 |
style = +"" |
|
947 |
style << "position: absolute;" |
|
948 |
style << "top:#{params[:top]}px;" |
|
949 |
style << "left:#{coords[:bar_start]}px;" |
|
950 |
style << "width:#{coords[:bar_end] - coords[:bar_start]}px;" |
|
951 |
style << "height:12px;" |
|
952 |
output << view.content_tag(:div, s.html_safe, |
|
953 |
:style => style, |
|
954 |
:class => "tooltip hascontextmenu", |
|
955 |
:data => data_options) |
|
956 |
end |
|
1040 |
output = view.content_tag(:div, output.html_safe, |
|
1041 |
:class => "#{css} line", |
|
1042 |
:style => "top:#{params[:top]}px;width:#{params[:g_width] - 1}px;", |
|
1043 |
:data => data_options |
|
1044 |
) |
|
957 | 1045 |
@lines << output |
958 | 1046 |
output |
959 | 1047 |
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_done.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}); |
... | ... | |
303 | 305 |
$('#available_c, #selected_c').children("[value='" + value + "']").prop('disabled', true); |
304 | 306 |
}); |
305 | 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 (_, 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_from').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 |
} else { |
|
378 |
json_param[key] = value; |
|
379 |
} |
|
380 |
}); |
|
381 |
$('#selected_c option:not(:disabled)').prop('selected', false); |
|
382 |
Object.assign(json_param, { |
|
383 |
change_duration: { |
|
384 |
start_date: start_date, |
|
385 |
due_date: due_date, |
|
386 |
lock_version: object.lock_version, |
|
387 |
}, |
|
388 |
}); |
|
389 | ||
390 |
$.ajax({ |
|
391 |
type: 'PUT', |
|
392 |
url: url, |
|
393 |
data: json_param, |
|
394 |
dataType: 'script', |
|
395 |
}).done(function (_) { |
|
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 | ||
---|---|---|
1496 | 1496 | |
1497 | 1497 |
.task { |
1498 | 1498 |
position: absolute; |
1499 |
height:8px;
|
|
1499 |
height:10px;
|
|
1500 | 1500 |
font-size:0.8em; |
1501 | 1501 |
color:#888; |
1502 | 1502 |
padding:0; |
... | ... | |
1505 | 1505 |
white-space:nowrap; |
1506 | 1506 |
} |
1507 | 1507 | |
1508 |
.task.label {width:100%;} |
|
1509 |
.task.label.project, .task.label.version { font-weight: bold; } |
|
1508 |
.task.line { left: 0; } |
|
1509 |
.task div.tooltip:hover span.tip { font-size: inherit; } |
|
1510 |
.task .task_todo .label { font-size: inherit; } |
|
1511 |
.task.project .task_todo .label { margin-top: -4px; } |
|
1512 |
.task.version .task_todo .label { margin-top: -3px; } |
|
1510 | 1513 | |
1511 |
.task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; } |
|
1512 |
.task_done { background:#00c600 url(../images/task_done.png); border: 1px solid #00c600; } |
|
1513 |
.task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; } |
|
1514 |
.task .label { position: absolute; width: auto; } |
|
1515 |
.task.project .label, .task.version .label { font-weight: bold; } |
|
1514 | 1516 | |
1515 |
.task_todo.parent { background: #888; border: 1px solid #888; height: 3px;} |
|
1516 |
.task_late.parent, .task_done.parent { height: 3px;} |
|
1517 |
.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;} |
|
1518 |
.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;} |
|
1517 |
.task_late { position: absolute; height: inherit; background:#f66; } |
|
1518 |
.task_done { position: absolute; height: inherit; background:#00c600; } |
|
1519 |
.task_todo { position: absolute; height: inherit; background:#aaa; } |
|
1519 | 1520 | |
1520 |
.version.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;} |
|
1521 |
.version.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;} |
|
1522 |
.version.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;} |
|
1523 |
.version.marker { background-image:url(../images/version_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; } |
|
1521 |
.parent .task_todo { background: #888; height: 5px; } |
|
1522 |
.parent .task_late, .parent .task_done { height: 5px; } |
|
1523 |
.parent .marker { |
|
1524 |
background: #888; |
|
1525 |
display: inline-block; |
|
1526 |
position: absolute; |
|
1527 |
width: 8px; |
|
1528 |
height: 6px; |
|
1529 |
margin-left: -5px; |
|
1530 |
margin-bottom: -4px; |
|
1531 |
} |
|
1532 | ||
1533 |
.version .task_late { background:#f66; height: 4px; } |
|
1534 |
.version .task_done { background:#00c600; height: 4px; } |
|
1535 |
.version .task_todo { background:#aaa; height: 4px; margin-top: 3px; } |
|
1536 |
.verison .marker { |
|
1537 |
width: 0; |
|
1538 |
height: 0; |
|
1539 |
border: 5px solid trasnparent; |
|
1540 |
border-bottom-color: black; |
|
1541 |
position: absolute; |
|
1542 |
margin-top: -5px; |
|
1543 |
margin-left: -6px; |
|
1544 |
} |
|
1545 |
.version .marker:after { |
|
1546 |
content: ''; |
|
1547 |
position: absolute; |
|
1548 |
left: -5px; |
|
1549 |
top: 5px; |
|
1550 |
width: 0; |
|
1551 |
height: 0; |
|
1552 |
border: 5px solid transparent; |
|
1553 |
border-top-color: black; |
|
1554 |
} |
|
1524 | 1555 | |
1525 |
.project.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;} |
|
1526 |
.project.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;} |
|
1527 |
.project.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;} |
|
1528 |
.project.marker { background-image:url(../images/project_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; } |
|
1556 |
.project .task_late { background:#f66; height: 2px; } |
|
1557 |
.project .task_done { background:#00c600; height: 2px; } |
|
1558 |
.project .task_todo { background:#aaa; height: 2px; margin-top: 4px; } |
|
1559 |
.project .marker { |
|
1560 |
width: 0; |
|
1561 |
height: 0; |
|
1562 |
border: 5px solid transparent; |
|
1563 |
border-bottom-color: blue; |
|
1564 |
position: absolute; |
|
1565 |
margin-top: -5px; |
|
1566 |
margin-left: -6px; |
|
1567 |
} |
|
1568 |
.project .marker:after { |
|
1569 |
content: ''; |
|
1570 |
position: absolute; |
|
1571 |
left: -5px; |
|
1572 |
top: 5px; |
|
1573 |
width: 0; |
|
1574 |
height: 0; |
|
1575 |
border: 5px solid transparent; |
|
1576 |
border-top-color: blue; |
|
1577 |
} |
|
1529 | 1578 | |
1530 | 1579 |
.version-behind-schedule a, .issue-behind-schedule a {color: #f66914;} |
1531 | 1580 |
.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 /issues/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", :text => /#{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 |
- « Previous
- 1
- …
- 33
- 34
- 35
- Next »