From 86f32ad198337d89d7d53091efa688ef95eb41e5 Mon Sep 17 00:00:00 2001 From: Katsuya HIDAKA Date: Thu, 9 Oct 2025 07:43:30 +0900 Subject: Extract Gantt view structure and wire Stimulus controllers --- app/assets/javascripts/gantt.js | 318 ------------- app/assets/stylesheets/application.css | 128 +----- app/assets/stylesheets/gantt.css | 261 +++++++++++ app/helpers/gantt_helper.rb | 43 ++ .../controllers/gantt/chart_controller.js | 357 +++++++++++++++ .../controllers/gantt/column_controller.js | 76 ++++ .../controllers/gantt/options_controller.js | 63 +++ .../controllers/gantt/subjects_controller.js | 122 +++++ app/views/gantts/_chart.html.erb | 255 +++++++++++ app/views/gantts/_query_form.html.erb | 126 ++++++ app/views/gantts/show.html.erb | 424 +----------------- lib/redmine/helpers/gantt.rb | 5 +- test/system/{gantt_test.rb => gantts_test.rb} | 42 +- 13 files changed, 1339 insertions(+), 881 deletions(-) delete mode 100644 app/assets/javascripts/gantt.js create mode 100644 app/assets/stylesheets/gantt.css create mode 100644 app/javascript/controllers/gantt/chart_controller.js create mode 100644 app/javascript/controllers/gantt/column_controller.js create mode 100644 app/javascript/controllers/gantt/options_controller.js create mode 100644 app/javascript/controllers/gantt/subjects_controller.js create mode 100644 app/views/gantts/_chart.html.erb create mode 100644 app/views/gantts/_query_form.html.erb rename test/system/{gantt_test.rb => gantts_test.rb} (60%) diff --git a/app/assets/javascripts/gantt.js b/app/assets/javascripts/gantt.js deleted file mode 100644 index ceb6d9a51..000000000 --- a/app/assets/javascripts/gantt.js +++ /dev/null @@ -1,318 +0,0 @@ -/** - * Redmine - project management software - * Copyright (C) 2006- Jean-Philippe Lang - * This code is released under the GNU General Public License. - */ - -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_right = $("#gantt_draw_area").width(); - draw_left = $("#gantt_area").scrollLeft(); -} - -function getRelationsArray() { - var arr = new Array(); - $.each($('div.task_todo[data-rels]'), function(index_div, element) { - if(!$(element).is(':visible')) return true; - var element_id = $(element).attr("id"); - if (element_id != null) { - var issue_id = element_id.replace("task-todo-issue-", ""); - var data_rels = $(element).data("rels"); - for (rel_type_key in data_rels) { - $.each(data_rels[rel_type_key], function(index_issue, element_issue) { - arr.push({issue_from: issue_id, issue_to: element_issue, - rel_type: rel_type_key}); - }); - } - } - }); - return arr; -} - -function drawRelations() { - var arr = getRelationsArray(); - $.each(arr, function(index_issue, element_issue) { - var issue_from = $("#task-todo-issue-" + element_issue["issue_from"]); - var issue_to = $("#task-todo-issue-" + element_issue["issue_to"]); - if (issue_from.length == 0 || issue_to.length == 0) { - return; - } - var issue_height = issue_from.height(); - var issue_from_top = issue_from.position().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_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]) - .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]) - .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]) - .attr({stroke: color, - "stroke-width": rels_stroke_width - }); - } else { - 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]) - .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]) - .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]) - .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]) - .attr({stroke: color, - "stroke-width": rels_stroke_width - }); - } - draw_gantt.path(["M", issue_to_left + draw_left, issue_to_top, - "l", -4 * rels_stroke_width, -2 * rels_stroke_width, - "l", 0, 4 * rels_stroke_width, "z"]) - .attr({stroke: "none", - fill: color, - "stroke-linecap": "butt", - "stroke-linejoin": "miter" - }); - }); -} - -function getProgressLinesArray() { - var arr = new Array(); - var today_left = $('#today_line').position().left; - 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 h = ($(element).height() / 9); - var element_top_upper = t - h; - var element_top_center = t + (h * 3); - var element_top_lower = t + (h * 8); - var issue_closed = $(element).children('span').hasClass('issue-closed'); - var version_closed = $(element).children('span').hasClass('version-closed'); - if (issue_closed || version_closed) { - arr.push({left: today_left, top: element_top_center}); - } else { - var issue_done = $("#task-done-" + $(element).attr("id")); - var is_behind_start = $(element).children('span').hasClass('behind-start-date'); - var is_over_end = $(element).children('span').hasClass('over-end-date'); - if (is_over_end) { - 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(); - 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}); - arr.push({left: 0 , top: element_top_lower, is_left_edge: true, none_stroke: true}); - } else { - var todo_left = today_left; - var issue_todo = $("#task-todo-" + $(element).attr("id")); - if (issue_todo.length > 0){ - todo_left = issue_todo.first().position().left; - } - arr.push({left: Math.min(today_left, todo_left), top: element_top_center}); - } - } - }); - return arr; -} - -function drawGanttProgressLines() { - var arr = getProgressLinesArray(); - var color = $("#today_line") - .css("border-left-color"); - var i; - for(i = 1 ; i < arr.length ; i++) { - if (!("none_stroke" in arr[i]) && - (!("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; - draw_gantt.path(["M", x1, arr[i - 1].top, - "L", x2, arr[i].top]) - .attr({stroke: color, "stroke-width": 2}); - } - } -} - -function drawSelectedColumns(){ - if ($("#draw_selected_columns").prop('checked')) { - if(isMobile()) { - $('td.gantt_selected_column').each(function(i) { - $(this).hide(); - }); - }else{ - $('.gantt_subjects_container').addClass('draw_selected_columns'); - $('td.gantt_selected_column').each(function() { - $(this).show(); - var column_name = $(this).attr('id'); - $(this).resizable({ - zIndex: 30, - alsoResize: '.gantt_' + column_name + '_container, .gantt_' + column_name + '_container > .gantt_hdr', - minWidth: 20, - handles: "e", - create: function() { - $(".ui-resizable-e").css("cursor","ew-resize"); - } - }).on('resize', function (e) { - e.stopPropagation(); - }); - }); - } - }else{ - $('td.gantt_selected_column').each(function (i) { - $(this).hide(); - $('.gantt_subjects_container').removeClass('draw_selected_columns'); - }); - } -} - -function drawGanttHandler() { - var folder = document.getElementById('gantt_draw_area'); - if(draw_gantt != null) - draw_gantt.clear(); - else - draw_gantt = Raphael(folder); - setDrawArea(); - drawSelectedColumns(); - if ($("#draw_progress_line").prop('checked')) - try{drawGanttProgressLines();}catch(e){} - if ($("#draw_relations").prop('checked')) - drawRelations(); - $('#content').addClass('gantt_content'); -} - -function resizableSubjectColumn(){ - $('.issue-subject, .project-name, .version-name').each(function(){ - $(this).width($(".gantt_subjects_column").width()-$(this).position().left); - }); - $('td.gantt_subjects_column').resizable({ - alsoResize: '.gantt_subjects_container, .gantt_subjects_container>.gantt_hdr, .project-name, .issue-subject, .version-name', - minWidth: 100, - handles: 'e', - zIndex: 30, - create: function( event, ui ) { - $('.ui-resizable-e').css('cursor','ew-resize'); - } - }).on('resize', function (e) { - e.stopPropagation(); - }); - if(isMobile()) { - $('td.gantt_subjects_column').resizable('disable'); - }else{ - $('td.gantt_subjects_column').resizable('enable'); - }; -} - -ganttEntryClick = function(e){ - var icon_expander = e.currentTarget; - var subject = $(icon_expander.parentElement); - var subject_left = parseInt(subject.css('left')) + parseInt(icon_expander.offsetWidth); - var target_shown = null; - var target_top = 0; - var total_height = 0; - var out_of_hierarchy = false; - var iconChange = null; - if(subject.hasClass('open')) - iconChange = function(element){ - var expander = $(element).find('.expander') - expander.switchClass('icon-expanded', 'icon-collapsed'); - $(element).removeClass('open'); - if (expander.find('svg').length === 1) { - updateSVGIcon(expander[0], 'angle-right') - } - }; - else - iconChange = function(element){ - var expander = $(element).find('.expander') - expander.find('.expander').switchClass('icon-collapsed', 'icon-expanded'); - $(element).addClass('open'); - if (expander.find('svg').length === 1) { - updateSVGIcon(expander[0], 'angle-down') - } - }; - iconChange(subject); - subject.nextAll('div').each(function(_, element){ - var el = $(element); - var json = el.data('collapse-expand'); - var number_of_rows = el.data('number-of-rows'); - var el_task_bars = '#gantt_area form > div[data-collapse-expand="' + json.obj_id + '"][data-number-of-rows="' + number_of_rows + '"]'; - var el_selected_columns = 'td.gantt_selected_column div[data-collapse-expand="' + json.obj_id + '"][data-number-of-rows="' + number_of_rows + '"]'; - if(out_of_hierarchy || parseInt(el.css('left')) <= subject_left){ - out_of_hierarchy = true; - if(target_shown == null) return false; - - var new_top_val = parseInt(el.css('top')) + total_height * (target_shown ? -1 : 1); - el.css('top', new_top_val); - $([el_task_bars, el_selected_columns].join()).each(function(_, el){ - $(el).css('top', new_top_val); - }); - return true; - } - - var is_shown = el.is(':visible'); - if(target_shown == null){ - target_shown = is_shown; - target_top = parseInt(el.css('top')); - total_height = 0; - } - if(is_shown == target_shown){ - $(el_task_bars).each(function(_, task) { - var el_task = $(task); - if(!is_shown) - el_task.css('top', target_top + total_height); - if(!el_task.hasClass('tooltip')) - el_task.toggle(!is_shown); - }); - $(el_selected_columns).each(function (_, attr) { - var el_attr = $(attr); - if (!is_shown) - el_attr.css('top', target_top + total_height); - el_attr.toggle(!is_shown); - }); - if(!is_shown) - el.css('top', target_top + total_height); - iconChange(el); - el.toggle(!is_shown); - total_height += parseInt(json.top_increment); - } - }); - drawGanttHandler(); -}; - -function disable_unavailable_columns(unavailable_columns) { - $.each(unavailable_columns, function (index, value) { - $('#available_c, #selected_c').children("[value='" + value + "']").prop('disabled', true); - }); -} diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 09773c709..22f4d3c0f 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -434,9 +434,7 @@ tr.entry td.age { text-align: right; } tr.entry.file td.filename a { margin-left: 26px; } tr.entry.file td.filename_no_report a { margin-left: 16px; } -tr span.expander, .gantt_subjects div > span.expander {margin-left: 0; cursor: pointer;} -.gantt_subjects .avatar {margin-right: 4px;} -.gantt_subjects div.project-name a, .gantt_subjects div.version-name a {margin-left: 4px;} +tr span.expander {margin-left: 0; cursor: pointer;} tr.changeset { height: 20px } tr.changeset ul, ol { margin-top: 0px; margin-bottom: 0px; } @@ -1705,130 +1703,6 @@ div.wiki .task-list input.task-list-item-checkbox { #my-page .list th.checkbox, #my-page .list td.checkbox {display:none;} -/***** Gantt chart *****/ -table.gantt-table { - width: 100%; - border-collapse: collapse; -} -table.gantt-table td { - padding: 0px; -} -.gantt_hdr { - position:absolute; - top:0; - height:16px; - border-top: 1px solid var(--oc-gray-4); - border-bottom: 1px solid var(--oc-gray-4); - border-left: 1px solid var(--oc-gray-4); - text-align: center; - overflow: hidden; -} -#gantt_area .gantt_hdr { - border-left: 0px; - border-right: 1px solid var(--oc-gray-4); -} -.gantt_subjects_container:not(.draw_selected_columns) .gantt_hdr, -.last_gantt_selected_column .gantt_hdr { - border-right: 1px solid var(--oc-gray-4); -} -.last_gantt_selected_column .gantt_selected_column_container, -.gantt_subjects_container .gantt_subjects * { - z-index: 10; -} - -.gantt_subjects_column + td { - padding: 0; -} - -.gantt_hdr.nwday {background-color:var(--oc-gray-1); color:var(--oc-gray-6);} - -.gantt_subjects, -.gantt_selected_column_content.gantt_hdr { - font-size: 0.8em; - position: relative; - z-index: 1; -} -.gantt_subjects div, -.gantt_selected_column_content div { - line-height: 16px; - height: 16px; - overflow: hidden; - white-space: nowrap; - text-overflow: clip; - width: 100%; -} -.gantt_subjects div.issue-subject:hover { background-color:var(--oc-yellow-0); } -.gantt_selected_column_content > div { padding-left: 3px; box-sizing: border-box; } - -.gantt_hdr_selected_column_name { - position: absolute; - top: 50%; - width:100%; - transform: translateY(-50%); - -webkit-transform: translateY(-50%); - font-size: 0.8em; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - -} -td.gantt_selected_column { - width: 50px; -} -td.gantt_selected_column .gantt_hdr,.gantt_selected_column_container { - width: 49px; -} - -td.gantt_watcher_users_column div.issue_watcher_users ul { - margin: 0; - padding: 0; - list-style: none; -} - -td.gantt_watcher_users_column div.issue_watcher_users ul li { - display: inline; -} - -td.gantt_watcher_users_column div.issue_watcher_users ul li:not(:last-child)::after { - content: ', '; - white-space: pre; -} - -.task { - position: absolute; - height:8px; - font-size:0.8em; - color:var(--oc-gray-6); - padding:0; - margin:0; - line-height:16px; - white-space:nowrap; -} - -.task.label {width:100%;} -.task.label.project, .task.label.version { font-weight: bold; } - -.task_late { background:var(--oc-red-5) url(/task_late.png); border: 1px solid var(--oc-red-5); } -.task_done { background:var(--oc-green-7) url(/task_done.png); border: 1px solid var(--oc-green-7); } -.task_todo { background:var(--oc-gray-5) url(/task_todo.png); border: 1px solid var(--oc-gray-5); } - -.task_todo.parent { background: var(--oc-gray-6); border: 1px solid var(--oc-gray-6); height: 3px;} -.task_late.parent, .task_done.parent { height: 3px;} -.task.parent.marker.starting { position: absolute; background: url(/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(/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; right: 0px; top: -1px;} - -.version.task_late { background:var(oc-red-5) url(/milestone_late.png); border: 1px solid var(oc-red-5); height: 2px; margin-top: 3px;} -.version.task_done { background:var(--oc-green-7) url(/milestone_done.png); border: 1px solid var(--oc-green-7); height: 2px; margin-top: 3px;} -.version.task_todo { background:var(--oc-white) url(/milestone_todo.png); border: 1px solid var(--oc-white); height: 2px; margin-top: 3px;} -.version.marker { background-image:url(/version_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; } - -.project.task_late { background:var(oc-red-5) url(/milestone_late.png); border: 1px solid var(oc-red-5); height: 2px; margin-top: 3px;} -.project.task_done { background:var(--oc-green-7) url(/milestone_done.png); border: 1px solid var(--oc-green-7); height: 2px; margin-top: 3px;} -.project.task_todo { background:var(--oc-white) url(/milestone_todo.png); border: 1px solid var(--oc-white); height: 2px; margin-top: 3px;} -.project.marker { background-image:url(/project_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; } - -.version-behind-schedule a, .issue-behind-schedule a {color: var(--oc-yellow-8);} -.version-overdue a, .issue-overdue a, .project-overdue a {color: var(--oc-red-8);} /***** User events (ex: journal, notes, replies, comments) *****/ .journals h4.journal-header { diff --git a/app/assets/stylesheets/gantt.css b/app/assets/stylesheets/gantt.css new file mode 100644 index 000000000..5757b0b09 --- /dev/null +++ b/app/assets/stylesheets/gantt.css @@ -0,0 +1,261 @@ +/** + * Redmine - project management software + * Copyright (C) 2006- Jean-Philippe Lang + * This code is released under the GNU General Public License. + */ + +.gantt_subjects div > span.expander { + margin-left: 0; + cursor: pointer; +} + +.gantt_subjects .avatar { + margin-right: 4px; +} + +.gantt_subjects div.project-name a, +.gantt_subjects div.version-name a { + margin-left: 4px; +} + +/***** Gantt chart *****/ +table.gantt-table { + width: 100%; + border-collapse: collapse; +} + +table.gantt-table td { + padding: 0; +} + +.gantt_hdr { + position: absolute; + top: 0; + height: 16px; + border-top: 1px solid var(--oc-gray-4); + border-bottom: 1px solid var(--oc-gray-4); + border-left: 1px solid var(--oc-gray-4); + text-align: center; + overflow: hidden; +} + +#gantt_area .gantt_hdr { + border-left: 0; + border-right: 1px solid var(--oc-gray-4); +} + +.gantt_subjects_container:not(.draw_selected_columns) .gantt_hdr, +.last_gantt_selected_column .gantt_hdr { + border-right: 1px solid var(--oc-gray-4); +} + +.last_gantt_selected_column .gantt_selected_column_container, +.gantt_subjects_container .gantt_subjects * { + z-index: 10; +} + +.gantt_subjects_column + td { + padding: 0; +} + +.gantt_hdr.nwday { + background-color: var(--oc-gray-1); + color: var(--oc-gray-6); +} + +.gantt_subjects, +.gantt_selected_column_content.gantt_hdr { + font-size: 0.8em; + position: relative; + z-index: 1; +} + +.gantt_subjects div, +.gantt_selected_column_content div { + line-height: 16px; + height: 16px; + overflow: hidden; + white-space: nowrap; + text-overflow: clip; + width: 100%; +} + +.gantt_subjects div.issue-subject:hover { + background-color: var(--oc-yellow-0); +} + +.gantt_selected_column_content > div { + padding-left: 3px; + box-sizing: border-box; +} + +.gantt_hdr_selected_column_name { + position: absolute; + top: 50%; + width: 100%; + transform: translateY(-50%); + -webkit-transform: translateY(-50%); + font-size: 0.8em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +td.gantt_selected_column { + width: 50px; +} + +td.gantt_selected_column .gantt_hdr, +.gantt_selected_column_container { + width: 49px; +} + +td.gantt_watcher_users_column div.issue_watcher_users ul { + margin: 0; + padding: 0; + list-style: none; +} + +td.gantt_watcher_users_column div.issue_watcher_users ul li { + display: inline; +} + +td.gantt_watcher_users_column div.issue_watcher_users ul li:not(:last-child)::after { + content: ', '; + white-space: pre; +} + +.task { + position: absolute; + height: 8px; + font-size: 0.8em; + color: var(--oc-gray-6); + padding: 0; + margin: 0; + line-height: 16px; + white-space: nowrap; +} + +.task.label { + width: 100%; +} + +.task.label.project, +.task.label.version { + font-weight: bold; +} + +.task_late { + background: var(--oc-red-5) url(/task_late.png); + border: 1px solid var(--oc-red-5); +} + +.task_done { + background: var(--oc-green-7) url(/task_done.png); + border: 1px solid var(--oc-green-7); +} + +.task_todo { + background: var(--oc-gray-5) url(/task_todo.png); + border: 1px solid var(--oc-gray-5); +} + +.task_todo.parent { + background: var(--oc-gray-6); + border: 1px solid var(--oc-gray-6); + height: 3px; +} + +.task_late.parent, +.task_done.parent { + height: 3px; +} + +.task.parent.marker.starting { + position: absolute; + background: url(/task_parent_end.png) no-repeat 0 0; + width: 8px; + height: 16px; + margin-left: -4px; + left: 0; + top: -1px; +} + +.task.parent.marker.ending { + position: absolute; + background: url(/task_parent_end.png) no-repeat 0 0; + width: 8px; + height: 16px; + margin-left: -4px; + right: 0; + top: -1px; +} + +.version.task_late { + background: var(oc-red-5) url(/milestone_late.png); + border: 1px solid var(oc-red-5); + height: 2px; + margin-top: 3px; +} + +.version.task_done { + background: var(--oc-green-7) url(/milestone_done.png); + border: 1px solid var(--oc-green-7); + height: 2px; + margin-top: 3px; +} + +.version.task_todo { + background: var(--oc-white) url(/milestone_todo.png); + border: 1px solid var(--oc-white); + height: 2px; + margin-top: 3px; +} + +.version.marker { + background-image: url(/version_marker.png); + background-repeat: no-repeat; + border: 0; + margin-left: -4px; + margin-top: 1px; +} + +.project.task_late { + background: var(oc-red-5) url(/milestone_late.png); + border: 1px solid var(oc-red-5); + height: 2px; + margin-top: 3px; +} + +.project.task_done { + background: var(--oc-green-7) url(/milestone_done.png); + border: 1px solid var(--oc-green-7); + height: 2px; + margin-top: 3px; +} + +.project.task_todo { + background: var(--oc-white) url(/milestone_todo.png); + border: 1px solid var(--oc-white); + height: 2px; + margin-top: 3px; +} + +.project.marker { + background-image: url(/project_marker.png); + background-repeat: no-repeat; + border: 0; + margin-left: -4px; + margin-top: 1px; +} + +.version-behind-schedule a, +.issue-behind-schedule a { + color: var(--oc-yellow-8); +} + +.version-overdue a, +.issue-overdue a, +.project-overdue a { + color: var(--oc-red-8); +} diff --git a/app/helpers/gantt_helper.rb b/app/helpers/gantt_helper.rb index 054aad366..b86e3dbbf 100644 --- a/app/helpers/gantt_helper.rb +++ b/app/helpers/gantt_helper.rb @@ -41,4 +41,47 @@ module GanttHelper end end end + + def gantt_chart_tag(query, &) + data_attributes = { + controller: 'gantt--chart', + # Events emitted by child controllers the chart listens to. + # - `gantt--options` toggles checkboxes under Options. + # - `gantt--subjects` reports tree expand/collapse. + # - Window resize triggers a redraw of progress lines and relations. + action: %w( + gantt--options:toggle-display@document->gantt--chart#handleOptionsDisplay + gantt--options:toggle-relations@document->gantt--chart#handleOptionsRelations + gantt--options:toggle-progress@document->gantt--chart#handleOptionsProgress + gantt--subjects:toggle-tree->gantt--chart#handleSubjectTreeChanged + resize@window->gantt--chart#handleWindowResize + ).join(' '), + 'gantt--chart-issue-relation-types-value': Redmine::Helpers::Gantt::DRAW_TYPES.to_json, + 'gantt--chart-show-selected-columns-value': query.draw_selected_columns ? 'true' : 'false', + 'gantt--chart-show-relations-value': query.draw_relations ? 'true' : 'false', + 'gantt--chart-show-progress-value': query.draw_progress_line ? 'true' : 'false' + } + + tag.table(class: 'gantt-table', data: data_attributes, &) + end + + def gantt_column_tag(column_name, min_width: nil, **options, &) + options[:data] = { + controller: 'gantt--column', + action: 'resize@window->gantt--column#handleWindowResize', + 'gantt--column-min-width-value': min_width, + 'gantt--column-column-value': column_name + } + options[:class] = ["gantt_#{column_name}_column", options[:class]] + + tag.td(**options, &) + end + + def gantt_subjects_tag(&) + data_attributes = { + controller: 'gantt--subjects', + action: 'gantt--column:resize-column-subjects@document->gantt--subjects#handleResizeColumn' + } + tag.div(class: "gantt_subjects", data: data_attributes, &) + end end diff --git a/app/javascript/controllers/gantt/chart_controller.js b/app/javascript/controllers/gantt/chart_controller.js new file mode 100644 index 000000000..1ea93c1ea --- /dev/null +++ b/app/javascript/controllers/gantt/chart_controller.js @@ -0,0 +1,357 @@ +import { Controller } from "@hotwired/stimulus" + +const RELATION_STROKE_WIDTH = 2 + +export default class extends Controller { + static targets = ["ganttArea", "drawArea", "subjectsContainer"] + + static values = { + issueRelationTypes: Object, + showSelectedColumns: Boolean, + showRelations: Boolean, + showProgress: Boolean + } + + #drawTop = 0 + #drawRight = 0 + #drawLeft = 0 + #drawPaper = null + + initialize() { + this.$ = window.jQuery + this.Raphael = window.Raphael + } + + connect() { + this.#drawTop = 0 + this.#drawRight = 0 + this.#drawLeft = 0 + + this.#drawProgressLineAndRelations() + this.#drawSelectedColumns() + } + + disconnect() { + if (this.#drawPaper) { + this.#drawPaper.remove() + this.#drawPaper = null + } + } + + showSelectedColumnsValueChanged() { + this.#drawSelectedColumns() + } + + showRelationsValueChanged() { + this.#drawProgressLineAndRelations() + } + + showProgressValueChanged() { + this.#drawProgressLineAndRelations() + } + + handleWindowResize() { + this.#drawProgressLineAndRelations() + this.#drawSelectedColumns() + } + + handleSubjectTreeChanged() { + this.#drawProgressLineAndRelations() + this.#drawSelectedColumns() + } + + handleOptionsDisplay(event) { + this.showSelectedColumnsValue = !!(event.detail && event.detail.enabled) + } + + handleOptionsRelations(event) { + this.showRelationsValue = !!(event.detail && event.detail.enabled) + } + + handleOptionsProgress(event) { + this.showProgressValue = !!(event.detail && event.detail.enabled) + } + + #drawProgressLineAndRelations() { + if (this.#drawPaper) { + this.#drawPaper.clear() + } else { + this.#drawPaper = this.Raphael(this.drawAreaTarget) + } + + this.#setupDrawArea() + + if (this.showProgressValue) { + this.#drawGanttProgressLines() + } + + if (this.showRelationsValue) { + this.#drawRelations() + } + + const content = document.getElementById("content") + if (content) { + content.classList.add("gantt_content") + } + } + + #setupDrawArea() { + const $drawArea = this.$(this.drawAreaTarget) + const $ganttArea = this.hasGanttAreaTarget ? this.$(this.ganttAreaTarget) : null + + this.#drawTop = $drawArea.position().top + this.#drawRight = $drawArea.width() + this.#drawLeft = $ganttArea ? $ganttArea.scrollLeft() : 0 + } + + #drawSelectedColumns() { + const $selectedColumns = this.$("td.gantt_selected_column") + const $subjectsContainer = this.$(".gantt_subjects_container") + + const isMobileDevice = typeof window.isMobile === "function" && window.isMobile() + + if (this.showSelectedColumnsValue) { + if (isMobileDevice) { + $selectedColumns.each((_, element) => { + this.$(element).hide() + }) + } else { + $subjectsContainer.addClass("draw_selected_columns") + $selectedColumns.show() + } + } else { + $selectedColumns.each((_, element) => { + this.$(element).hide() + }) + $subjectsContainer.removeClass("draw_selected_columns") + } + } + + get #relationsArray() { + const relations = [] + + this.$("div.task_todo[data-rels]").each((_, element) => { + const $element = this.$(element) + + if (!$element.is(":visible")) return + + const elementId = $element.attr("id") + + if (!elementId) return + + const issueId = elementId.replace("task-todo-issue-", "") + const dataRels = $element.data("rels") || {} + + Object.keys(dataRels).forEach((relTypeKey) => { + this.$.each(dataRels[relTypeKey], (_, relatedIssue) => { + relations.push({ issue_from: issueId, issue_to: relatedIssue, rel_type: relTypeKey }) + }) + }) + }) + + return relations + } + + #drawRelations() { + const relations = this.#relationsArray + + relations.forEach((relation) => { + const issueFrom = this.$(`#task-todo-issue-${relation.issue_from}`) + const issueTo = this.$(`#task-todo-issue-${relation.issue_to}`) + + if (issueFrom.length === 0 || issueTo.length === 0) return + + const issueHeight = issueFrom.height() + const issueFromTop = issueFrom.position().top + issueHeight / 2 - this.#drawTop + const issueFromRight = issueFrom.position().left + issueFrom.width() + const issueToTop = issueTo.position().top + issueHeight / 2 - this.#drawTop + const issueToLeft = issueTo.position().left + const relationConfig = this.issueRelationTypesValue[relation.rel_type] || {} + const color = relationConfig.color || "#000" + const landscapeMargin = relationConfig.landscape_margin || 0 + const issueFromRightRel = issueFromRight + landscapeMargin + const issueToLeftRel = issueToLeft - landscapeMargin + + this.#drawPaper + .path([ + "M", + issueFromRight + this.#drawLeft, + issueFromTop, + "L", + issueFromRightRel + this.#drawLeft, + issueFromTop + ]) + .attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH }) + + if (issueFromRightRel < issueToLeftRel) { + this.#drawPaper + .path([ + "M", + issueFromRightRel + this.#drawLeft, + issueFromTop, + "L", + issueFromRightRel + this.#drawLeft, + issueToTop + ]) + .attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH }) + this.#drawPaper + .path([ + "M", + issueFromRightRel + this.#drawLeft, + issueToTop, + "L", + issueToLeft + this.#drawLeft, + issueToTop + ]) + .attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH }) + } else { + const issueMiddleTop = issueToTop + issueHeight * (issueFromTop > issueToTop ? 1 : -1) + this.#drawPaper + .path([ + "M", + issueFromRightRel + this.#drawLeft, + issueFromTop, + "L", + issueFromRightRel + this.#drawLeft, + issueMiddleTop + ]) + .attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH }) + this.#drawPaper + .path([ + "M", + issueFromRightRel + this.#drawLeft, + issueMiddleTop, + "L", + issueToLeftRel + this.#drawLeft, + issueMiddleTop + ]) + .attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH }) + this.#drawPaper + .path([ + "M", + issueToLeftRel + this.#drawLeft, + issueMiddleTop, + "L", + issueToLeftRel + this.#drawLeft, + issueToTop + ]) + .attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH }) + this.#drawPaper + .path([ + "M", + issueToLeftRel + this.#drawLeft, + issueToTop, + "L", + issueToLeft + this.#drawLeft, + issueToTop + ]) + .attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH }) + } + this.#drawPaper + .path([ + "M", + issueToLeft + this.#drawLeft, + issueToTop, + "l", + -4 * RELATION_STROKE_WIDTH, + -2 * RELATION_STROKE_WIDTH, + "l", + 0, + 4 * RELATION_STROKE_WIDTH, + "z" + ]) + .attr({ + stroke: "none", + fill: color, + "stroke-linecap": "butt", + "stroke-linejoin": "miter" + }) + }) + } + + get #progressLinesArray() { + const lines = [] + const todayLeft = this.$("#today_line").position().left + + lines.push({ left: todayLeft, top: 0 }) + + this.$("div.issue-subject, div.version-name").each((_, element) => { + const $element = this.$(element) + + if (!$element.is(":visible")) return true + + const topPosition = $element.position().top - this.#drawTop + const elementHeight = $element.height() / 9 + const elementTopUpper = topPosition - elementHeight + const elementTopCenter = topPosition + elementHeight * 3 + const elementTopLower = topPosition + elementHeight * 8 + const issueClosed = $element.children("span").hasClass("issue-closed") + const versionClosed = $element.children("span").hasClass("version-closed") + + if (issueClosed || versionClosed) { + lines.push({ left: todayLeft, top: elementTopCenter }) + } else { + const issueDone = this.$(`#task-done-${$element.attr("id")}`) + const isBehindStart = $element.children("span").hasClass("behind-start-date") + const isOverEnd = $element.children("span").hasClass("over-end-date") + + if (isOverEnd) { + lines.push({ left: this.#drawRight, top: elementTopUpper, is_right_edge: true }) + lines.push({ + left: this.#drawRight, + top: elementTopLower, + is_right_edge: true, + none_stroke: true + }) + } else if (issueDone.length > 0) { + const doneLeft = issueDone.first().position().left + issueDone.first().width() + lines.push({ left: doneLeft, top: elementTopCenter }) + } else if (isBehindStart) { + lines.push({ left: 0, top: elementTopUpper, is_left_edge: true }) + lines.push({ + left: 0, + top: elementTopLower, + is_left_edge: true, + none_stroke: true + }) + } else { + let todoLeft = todayLeft + const issueTodo = this.$(`#task-todo-${$element.attr("id")}`) + if (issueTodo.length > 0) { + todoLeft = issueTodo.first().position().left + } + lines.push({ left: Math.min(todayLeft, todoLeft), top: elementTopCenter }) + } + } + }) + + return lines + } + + #drawGanttProgressLines() { + if (this.$("#today_line").length === 0) return + + const progressLines = this.#progressLinesArray + const color = this.$("#today_line").css("border-left-color") || "#ff0000" + + for (let index = 1; index < progressLines.length; index += 1) { + const current = progressLines[index] + const previous = progressLines[index - 1] + + if ( + !current.none_stroke && + !( + (previous.is_right_edge && current.is_right_edge) || + (previous.is_left_edge && current.is_left_edge) + ) + ) { + const x1 = previous.left === 0 ? 0 : previous.left + this.#drawLeft + const x2 = current.left === 0 ? 0 : current.left + this.#drawLeft + + this.#drawPaper + .path(["M", x1, previous.top, "L", x2, current.top]) + .attr({ stroke: color, "stroke-width": 2 }) + } + } + } +} diff --git a/app/javascript/controllers/gantt/column_controller.js b/app/javascript/controllers/gantt/column_controller.js new file mode 100644 index 000000000..ba9bf108b --- /dev/null +++ b/app/javascript/controllers/gantt/column_controller.js @@ -0,0 +1,76 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { + minWidth: Number, + column: String, + // Local value + mobileMode: { type: Boolean, default: false } + } + + #$element = null + + initialize() { + this.$ = window.jQuery + } + + connect() { + this.#$element = this.$(this.element) + this.#setupResizable() + this.#dispatchResizeColumn() + } + + disconnect() { + this.#$element?.resizable("destroy") + this.#$element = null + } + + handleWindowResize(_event) { + this.mobileModeValue = this.#isMobile() + + this.#dispatchResizeColumn() + } + + mobileModeValueChanged(current, old) { + if (current == old) return + + if (this.mobileModeValue) { + this.#$element?.resizable("disable") + } else { + this.#$element?.resizable("enable") + } + } + + #setupResizable() { + const alsoResize = [ + `.gantt_${this.columnValue}_container`, + `.gantt_${this.columnValue}_container > .gantt_hdr` + ] + const options = { + handles: "e", + minWidth: this.minWidthValue, + zIndex: 30, + alsoResize: alsoResize.join(","), + create: () => { + this.$(".ui-resizable-e").css("cursor", "ew-resize") + } + } + + this.#$element + .resizable(options) + .on("resize", (event) => { + event.stopPropagation() + this.#dispatchResizeColumn() + }) + } + + #dispatchResizeColumn() { + if (!this.#$element) return + + this.dispatch(`resize-column-${this.columnValue}`, { detail: { width: this.#$element.width() } }) + } + + #isMobile() { + return !!(typeof window.isMobile === "function" && window.isMobile()) + } +} diff --git a/app/javascript/controllers/gantt/options_controller.js b/app/javascript/controllers/gantt/options_controller.js new file mode 100644 index 000000000..c36018090 --- /dev/null +++ b/app/javascript/controllers/gantt/options_controller.js @@ -0,0 +1,63 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["display", "relations", "progress"] + + static values = { + unavailableColumns: Array + } + + initialize() { + this.$ = window.jQuery + } + + connect() { + this.#dispatchInitialStates() + this.#disableUnavailableColumns() + } + + toggleDisplay(event) { + this.dispatch("toggle-display", { + detail: { enabled: event.currentTarget.checked } + }) + } + + toggleRelations(event) { + this.dispatch("toggle-relations", { + detail: { enabled: event.currentTarget.checked } + }) + } + + toggleProgress(event) { + this.dispatch("toggle-progress", { + detail: { enabled: event.currentTarget.checked } + }) + } + + #dispatchInitialStates() { + if (this.hasDisplayTarget) { + this.dispatch("toggle-display", { + detail: { enabled: this.displayTarget.checked } + }) + } + if (this.hasRelationsTarget) { + this.dispatch("toggle-relations", { + detail: { enabled: this.relationsTarget.checked } + }) + } + if (this.hasProgressTarget) { + this.dispatch("toggle-progress", { + detail: { enabled: this.progressTarget.checked } + }) + } + } + + #disableUnavailableColumns() { + if (!Array.isArray(this.unavailableColumnsValue)) { + return + } + this.unavailableColumnsValue.forEach((column) => { + this.$("#available_c, #selected_c").children(`[value='${column}']`).prop("disabled", true) + }) + } +} diff --git a/app/javascript/controllers/gantt/subjects_controller.js b/app/javascript/controllers/gantt/subjects_controller.js new file mode 100644 index 000000000..8fdbb0954 --- /dev/null +++ b/app/javascript/controllers/gantt/subjects_controller.js @@ -0,0 +1,122 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + initialize() { + this.$ = window.jQuery + } + + handleResizeColumn(event) { + const columnWidth = event.detail.width; + + this.$(".issue-subject, .project-name, .version-name").each((_, element) => { + const $element = this.$(element) + $element.width(columnWidth - $element.position().left) + }) + } + + handleEntryClick(event) { + const iconExpander = event.currentTarget + const $subject = this.$(iconExpander.parentElement) + const subjectLeft = + parseInt($subject.css("left"), 10) + parseInt(iconExpander.offsetWidth, 10) + + let targetShown = null + let targetTop = 0 + let totalHeight = 0 + let outOfHierarchy = false + + const willOpen = !$subject.hasClass("open") + + this.#setIconState($subject, willOpen) + + $subject.nextAll("div").each((_, element) => { + const $element = this.$(element) + const json = $element.data("collapse-expand") + const numberOfRows = $element.data("number-of-rows") + const barsSelector = `#gantt_area form > div[data-collapse-expand='${json.obj_id}'][data-number-of-rows='${numberOfRows}']` + const selectedColumnsSelector = `td.gantt_selected_column div[data-collapse-expand='${json.obj_id}'][data-number-of-rows='${numberOfRows}']` + + if (outOfHierarchy || parseInt($element.css("left"), 10) <= subjectLeft) { + outOfHierarchy = true + + if (targetShown === null) return false + + const newTopVal = parseInt($element.css("top"), 10) + totalHeight * (targetShown ? -1 : 1) + + $element.css("top", newTopVal) + this.$([barsSelector, selectedColumnsSelector].join()).each((__, el) => { + this.$(el).css("top", newTopVal) + }) + + return true + } + + const isShown = $element.is(":visible") + + if (targetShown === null) { + targetShown = isShown + targetTop = parseInt($element.css("top"), 10) + totalHeight = 0 + } + + if (isShown === targetShown) { + this.$(barsSelector).each((__, task) => { + const $task = this.$(task) + + if (!isShown && willOpen) { + $task.css("top", targetTop + totalHeight) + } + if (!$task.hasClass("tooltip")) { + $task.toggle(willOpen) + } + }) + + this.$(selectedColumnsSelector).each((__, attr) => { + const $attr = this.$(attr) + + if (!isShown && willOpen) { + $attr.css("top", targetTop + totalHeight) + } + $attr.toggle(willOpen) + }) + + if (!isShown && willOpen) { + $element.css("top", targetTop + totalHeight) + } + + this.#setIconState($element, willOpen) + $element.toggle(willOpen) + totalHeight += parseInt(json.top_increment, 10) + } + }) + + this.dispatch("toggle-tree", { bubbles: true }) + } + + #setIconState(element, open) { + const $element = element.jquery ? element : this.$(element) + const expander = $element.find(".expander") + + if (open) { + $element.addClass("open") + + if (expander.length > 0) { + expander.removeClass("icon-collapsed").addClass("icon-expanded") + + if (expander.find("svg").length === 1) { + window.updateSVGIcon(expander[0], "angle-down") + } + } + } else { + $element.removeClass("open") + + if (expander.length > 0) { + expander.removeClass("icon-expanded").addClass("icon-collapsed") + + if (expander.find("svg").length === 1) { + window.updateSVGIcon(expander[0], "angle-right") + } + } + } + } +} diff --git a/app/views/gantts/_chart.html.erb b/app/views/gantts/_chart.html.erb new file mode 100644 index 000000000..870096870 --- /dev/null +++ b/app/views/gantts/_chart.html.erb @@ -0,0 +1,255 @@ +<% + zoom = 1 + gantt.zoom.times { zoom *= 2 } + + subject_width = 330 + header_height = 18 + + headers_height = header_height + show_weeks = false + show_days = false + show_day_num = false + + if gantt.zoom > 1 + show_weeks = true + headers_height = 2 * header_height + if gantt.zoom > 2 + show_days = true + headers_height = 3 * header_height + if gantt.zoom > 3 + show_day_num = true + headers_height = 4 * header_height + end + end + end + + g_width = ((gantt.date_to - gantt.date_from + 1) * zoom).to_i + gantt.render( + top: headers_height + 8, + zoom: zoom, + g_width: g_width, + subject_width: subject_width + ) + g_height = [(20 * (gantt.number_of_rows + 6)) + 150, 206].max + t_height = g_height + headers_height +%> + +<% if gantt.truncated %> +

<%= l(:notice_gantt_chart_truncated, max: gantt.max_rows) %>

+<% end %> + +<%= gantt_chart_tag(@query) do %> + + <%= gantt_column_tag('subjects', min_width: 100, + style: "width:#{query.draw_selected_columns ? subject_width + 1 : subject_width + 2}px;") do %> + <% + subjects_container_classes = "gantt_subjects_container" + subjects_container_classes << " draw_selected_columns" if query.draw_selected_columns + subjects_container_style = +"position:relative;" + subjects_container_style << "height: #{t_height + 24}px;" + subjects_container_style << "width: #{subject_width + 1}px;" + %> + <%= content_tag(:div, + style: subjects_container_style, + class: subjects_container_classes, + data: {'gantt--chart-target': 'subjectsContainer'}) do %> + <% + header_bg_style = +"width: #{subject_width + 1}px;" + header_bg_style << "height: #{headers_height}px;" + header_bg_style << 'background: #f1f3f5;' # oc-gray-1 + %> + <%= content_tag(:div, "", style: header_bg_style, class: "gantt_hdr") %> + <% + header_overlay_style = +"z-index: 1;" + header_overlay_style << "width: #{subject_width + 1}px;" + header_overlay_style << "height: #{t_height}px;" + header_overlay_style << 'overflow: hidden;' + %> + <%= content_tag(:div, "", style: header_overlay_style, class: "gantt_hdr") %> + <%= gantt_subjects_tag do %> + <%= form_tag({}, data: {cm_url: issues_context_menu_path}) do %> + <%= hidden_field_tag 'back_url', url_for(params: request.query_parameters), id: nil %> + <%= gantt.subjects.html_safe %> + <% end %> + <% end %> + <% end %> + <% end %> + <% + query.columns.each do |column| + next if Redmine::Helpers::Gantt::UNAVAILABLE_COLUMNS.include?(column.name) + column_name = column.name.to_s.tr('.', '_') + %> + <%= gantt_column_tag(column_name, min_width: 20, id: column_name, + class: ['gantt_selected_column', { 'last_gantt_selected_column': query.columns.last == column }]) do %> + <% + column_container_style = +"position: relative;" + column_container_style << "height: #{t_height + 24}px;" + %> + <%= content_tag(:div, style: column_container_style, class: "gantt_#{column_name}_container gantt_selected_column_container") do %> + <% + column_header_overlay_style = +"height: #{t_height}px;" + column_header_overlay_style << 'overflow: hidden;' + %> + <%= content_tag(:div, '', style: column_header_overlay_style, class: "gantt_hdr") %> + <% + column_header_style = +"height: #{headers_height}px;" + column_header_style << 'background: #f1f3f5;' # oc-gray-1 + %> + <%= content_tag(:div, + content_tag(:p, column.caption, class: 'gantt_hdr_selected_column_name'), + style: column_header_style, + class: "gantt_hdr") %> + <%= content_tag(:div, class: "gantt_#{column_name} gantt_selected_column_content") do %> + <%= gantt.selected_column_content({column: column, top: headers_height + 8, zoom: zoom, g_width: g_width}).html_safe %> + <% end %> + <% end %> + <% end %> + <% end %> + +
+ <% + months_header_style = +"width: #{g_width - 1}px;" + months_header_style << "height: #{headers_height}px;" + months_header_style << 'background: #f1f3f5;' # oc-gray-1 + %> + <%= content_tag(:div, ' '.html_safe, style: months_header_style, class: "gantt_hdr") %> + + <% month_f = gantt.date_from %> + <% left = 0 %> + <% months_height = (show_weeks ? header_height : header_height + g_height) %> + <% gantt.months.times do %> + <% width = (((month_f >> 1) - month_f) * zoom - 1).to_i %> + <% month_style = +"left: #{left}px;" %> + <% month_style << "width: #{width}px;" %> + <% month_style << "height: #{months_height}px;" %> + <%= content_tag(:div, style: month_style, class: "gantt_hdr") do %> + <%= link_to "#{month_f.year}-#{month_f.month}", + gantt.params.merge(year: month_f.year, month: month_f.month), + title: "#{month_name(month_f.month)} #{month_f.year}" %> + <% end %> + <% left += width + 1 %> + <% month_f = month_f >> 1 %> + <% end %> + + <% if show_weeks %> + <% left = 0 %> + <% weeks_height = (show_days ? header_height - 1 : header_height - 1 + g_height) %> + <% if gantt.date_from.cwday == 1 %> + <% week_f = gantt.date_from %> + <% else %> + <% week_f = gantt.date_from + (7 - gantt.date_from.cwday + 1) %> + <% width = (7 - gantt.date_from.cwday + 1) * zoom - 1 %> + <% gap_style = +"left: #{left}px;" %> + <% gap_style << "top: 19px;" %> + <% gap_style << "width: #{width}px;" %> + <% gap_style << "height: #{weeks_height}px;" %> + <%= content_tag(:div, ' '.html_safe, style: gap_style, class: "gantt_hdr") %> + <% left += width + 1 %> + <% end %> + <% while week_f <= gantt.date_to %> + <% width = ((week_f + 6 <= gantt.date_to) ? 7 * zoom - 1 : (gantt.date_to - week_f + 1) * zoom - 1).to_i %> + <% week_style = +"left: #{left}px;" %> + <% week_style << "top: 19px;" %> + <% week_style << "width: #{width}px;" %> + <% week_style << "height: #{weeks_height}px;" %> + <%= content_tag(:div, style: week_style, class: "gantt_hdr") do %> + <%= content_tag(:small) do %> + <%= week_f.cweek if width >= 16 %> + <% end %> + <% end %> + <% left += width + 1 %> + <% week_f += 7 %> + <% end %> + <% end %> + + <% if show_day_num %> + <% left = 0 %> + <% days_height = g_height + header_height * 2 - 1 %> + <% wday = gantt.date_from.cwday %> + <% day_num = gantt.date_from %> + <% (gantt.date_to - gantt.date_from + 1).to_i.times do %> + <% width = zoom - 1 %> + <% day_style = +"left:#{left}px;" %> + <% day_style << "top:37px;" %> + <% day_style << "width:#{width}px;" %> + <% day_style << "height:#{days_height}px;" %> + <% day_style << "font-size:0.7em;" %> + <% day_classes = +"gantt_hdr" %> + <% day_classes << " nwday" if gantt.non_working_week_days.include?(wday) %> + <%= content_tag(:div, style: day_style, class: day_classes) do %> + <%= day_num.day %> + <% end %> + <% left += width + 1 %> + <% day_num += 1 %> + <% wday += 1 %> + <% wday = 1 if wday > 7 %> + <% end %> + <% end %> + + <% if show_days %> + <% left = 0 %> + <% days_height = g_height + header_height - 1 %> + <% days_top = (show_day_num ? 55 : 37) %> + <% (gantt.date_from..gantt.date_to).each do |g_date| %> + <% width = zoom - 1 %> + <% day_style = +"left: #{left}px;" %> + <% day_style << "top: #{days_top}px;" %> + <% day_style << "width: #{width}px;" %> + <% day_style << "height: #{days_height}px;" %> + <% day_style << "font-size:0.7em;" %> + <% day_classes = +"gantt_hdr" %> + <% day_classes << " nwday" if gantt.non_working_week_days.include?(g_date.cwday) %> + <%= content_tag(:div, style: day_style, class: day_classes) do %> + <%= day_letter(g_date.cwday) %> + <% end %> + <% left += width + 1 %> + <% end %> + <% end %> + + <%= form_tag({}, data: {cm_url: issues_context_menu_path}) do %> + <%= hidden_field_tag 'back_url', url_for(params: request.query_parameters), id: nil %> + <%= gantt.lines.html_safe %> + <% end %> + + <% if User.current.today >= gantt.date_from && User.current.today <= gantt.date_to %> + <% today_left = (((User.current.today - gantt.date_from + 1) * zoom).floor - 1).to_i %> + <% today_style = +"position: absolute;" %> + <% today_style << "height: #{g_height}px;" %> + <% today_style << "top: #{headers_height + 1}px;" %> + <% today_style << "left: #{today_left}px;" %> + <% today_style << "width:10px;" %> + <% today_style << "border-left: 1px dashed red;" %> + <%= content_tag(:div, ' '.html_safe, style: today_style, id: 'today_line') %> + <% end %> + + <% + draw_area_style = +"position: absolute;" + draw_area_style << "height: #{g_height}px;" + draw_area_style << "top: #{headers_height + 1}px;" + draw_area_style << 'left: 0px;' + draw_area_style << "width: #{g_width - 1}px;" + %> + <%= content_tag(:div, '', style: draw_area_style, id: "gantt_draw_area", data: {'gantt--chart-target': 'drawArea'}) %> +
+ + +<% end %> + + + + + +<% other_formats_links do |f| %> + <%= f.link_to_with_query_parameters 'PDF', gantt.params %> + <%= f.link_to_with_query_parameters('PNG', gantt.params) if gantt.respond_to?('to_image') %> +<% end %> diff --git a/app/views/gantts/_query_form.html.erb b/app/views/gantts/_query_form.html.erb new file mode 100644 index 000000000..31b2eefaf --- /dev/null +++ b/app/views/gantts/_query_form.html.erb @@ -0,0 +1,126 @@ +<%= form_tag( + {controller: 'gantts', action: 'show', project_id: project, month: params[:month], year: params[:year], months: params[:months]}, + method: :get, + id: 'query_form', + data: {gantt_target: 'form'} + ) do %> + <%= hidden_field_tag 'set_filter', '1' %> + <%= hidden_field_tag 'gantt', '1' %> + +
+
+
+ + <%= sprite_icon(query.new_record? ? 'angle-down' : 'angle-right', rtl: !query.new_record?) %> + <%= l(:label_filter_plural) %> + +
+ <%= render partial: 'queries/filters', locals: {query: query} %> +
+
+ + +
+ +

+ + <%= gantt_zoom_link(gantt, :in) %> + <%= gantt_zoom_link(gantt, :out) %> + + + <%= link_to_previous_month(gantt.year_from, gantt.month_from, accesskey: accesskey(:previous)) %> | + <%= link_to_next_month(gantt.year_from, gantt.month_from, accesskey: accesskey(:next)) %> + +

+ +

+ <%= number_field_tag 'months', gantt.months, min: 1, max: Setting.gantt_months_limit.to_i, autocomplete: false %> + <%= l(:label_months_from) %> + <%= select_month(gantt.month_from, prefix: 'month', discard_type: true) %> + <%= select_year(gantt.year_from, prefix: 'year', discard_type: true) %> + <%= hidden_field_tag 'zoom', gantt.zoom %> + + <%= link_to_function sprite_icon('checked', l(:button_apply)), '$("#query_form").submit()', + class: 'icon icon-checked' %> + <%= link_to sprite_icon('reload', l(:button_clear)), {project_id: project, set_filter: 1}, + class: 'icon icon-reload' %> + <% if query.new_record? && User.current.allowed_to?(:save_queries, project, global: true) %> + <%= link_to_function sprite_icon('save', l(:button_save_object, object_name: l(:label_query)).capitalize), + "$('#query_form').attr('action', '#{ project ? new_project_query_path(project) : new_query_path }').submit();", + class: 'icon icon-save' %> + <% end %> + <% if !query.new_record? && query.editable_by?(User.current) %> + <%= link_to sprite_icon('edit', l(:button_edit_object, object_name: l(:label_query)).capitalize), + edit_query_path(query, gantt: 1), + class: 'icon icon-edit' %> + <%= delete_link query_path(query, gantt: 1), {}, l(:button_delete_object, object_name: l(:label_query)).capitalize %> + <% end %> +

+
+<% end %> diff --git a/app/views/gantts/show.html.erb b/app/views/gantts/show.html.erb index 08c153d38..b75afc5df 100644 --- a/app/views/gantts/show.html.erb +++ b/app/views/gantts/show.html.erb @@ -1,433 +1,29 @@ <% @gantt.view = self %> -
-
-

<%= @query.new_record? ? l(:label_gantt) : @query.name %>

-<%= @query.persisted? && @query.description.present? ? content_tag('p', @query.description, class: 'subtitle') : '' %> - -<%= form_tag({:controller => 'gantts', :action => 'show', - :project_id => @project, :month => params[:month], - :year => params[:year], :months => params[:months]}, - :method => :get, :id => 'query_form') do %> -<%= hidden_field_tag 'set_filter', '1' %> -<%= hidden_field_tag 'gantt', '1' %> - -
-
-
"> - "> - <%= sprite_icon(@query.new_record? ? "angle-down" : "angle-right", rtl: !@query.new_record?) %> - <%= l(:label_filter_plural) %> - -
"> - <%= render :partial => 'queries/filters', :locals => {:query => @query} %> -
-
- - -
+
-

- - <%= gantt_zoom_link(@gantt, :in) %> - <%= gantt_zoom_link(@gantt, :out) %> - - - <%= link_to_previous_month(@gantt.year_from, @gantt.month_from, :accesskey => accesskey(:previous)) %> | <%= link_to_next_month(@gantt.year_from, @gantt.month_from, :accesskey => accesskey(:next)) %> - -

- -

- <%= number_field_tag 'months', @gantt.months, :min => 1, :max => Setting.gantt_months_limit.to_i, :autocomplete => false %> - <%= l(:label_months_from) %> - <%= select_month(@gantt.month_from, :prefix => "month", :discard_type => true) %> - <%= select_year(@gantt.year_from, :prefix => "year", :discard_type => true) %> - <%= hidden_field_tag 'zoom', @gantt.zoom %> +

<%= @query.new_record? ? l(:label_gantt) : @query.name %>

- <%= link_to_function sprite_icon('checked', l(:button_apply)), '$("#query_form").submit()', - :class => 'icon icon-checked' %> - <%= link_to sprite_icon('reload', l(:button_clear)), { :project_id => @project, :set_filter => 1 }, - :class => 'icon icon-reload' %> - <% if @query.new_record? && User.current.allowed_to?(:save_queries, @project, :global => true) %> - <%= link_to_function sprite_icon('save', l(:button_save_object, object_name: l(:label_query)).capitalize), - "$('#query_form').attr('action', '#{ @project ? new_project_query_path(@project) : new_query_path }').submit();", - :class => 'icon icon-save' %> - <% end %> -<% if !@query.new_record? && @query.editable_by?(User.current) %> - <%= link_to sprite_icon('edit', l(:button_edit_object, object_name: l(:label_query)).capitalize), edit_query_path(@query, :gantt => 1), :class => 'icon icon-edit' %> - <%= delete_link query_path(@query, :gantt => 1), {}, l(:button_delete_object, object_name: l(:label_query)).capitalize %> -<% end %> -

-
+<% if @query.persisted? && @query.description.present? %> +<%= content_tag('p', @query.description, class: 'subtitle') %> <% end %> +<%= render partial: 'query_form', locals: {project: @project, query: @query, gantt: @gantt} %> <%= error_messages_for 'query' %> -<% if @query.valid? %> -<% - zoom = 1 - @gantt.zoom.times { zoom = zoom * 2 } - - subject_width = 330 - header_height = 18 - - headers_height = header_height - show_weeks = false - show_days = false - show_day_num = false - - if @gantt.zoom > 1 - show_weeks = true - headers_height = 2 * header_height - if @gantt.zoom > 2 - show_days = true - headers_height = 3 * header_height - if @gantt.zoom > 3 - show_day_num = true - headers_height = 4 * header_height - end - end - end - - # Width of the entire chart - g_width = ((@gantt.date_to - @gantt.date_from + 1) * zoom).to_i - @gantt.render(:top => headers_height + 8, - :zoom => zoom, - :g_width => g_width, - :subject_width => subject_width) - g_height = [(20 * (@gantt.number_of_rows + 6)) + 150, 206].max - t_height = g_height + headers_height -%> - -<% if @gantt.truncated %> -

<%= l(:notice_gantt_chart_truncated, :max => @gantt.max_rows) %>

-<% end %> - - - - -<% - @query.columns.each do |column| - next if Redmine::Helpers::Gantt::UNAVAILABLE_COLUMNS.include?(column.name) - column_name = column.name.to_s.tr('.', '_') -%> - -<% end %> - - -
- <% - style = "" - style += "position:relative;" - style += "height: #{t_height + 24}px;" - style += "width: #{subject_width + 1}px;" - %> - <%= content_tag(:div, :style => style, :class => "gantt_subjects_container #{'draw_selected_columns' if @query.draw_selected_columns}") do %> - <% - style = "" - style += "width: #{subject_width + 1}px;" - style += "height: #{headers_height}px;" - style += 'background: #f1f3f5;' # oc-gray-1 - %> - <%= content_tag(:div, "", :style => style, :class => "gantt_hdr") %> - <% - style = "" - style += "z-index: 1;" - style += "width: #{subject_width + 1}px;" - style += "height: #{t_height}px;" - style += 'overflow: hidden;' - %> - <%= content_tag(:div, "", :style => style, :class => "gantt_hdr") %> - <%= content_tag(:div, :class => "gantt_subjects") do %> - <%= form_tag({}, :data => {:cm_url => issues_context_menu_path}) do -%> - <%= hidden_field_tag 'back_url', url_for(:params => request.query_parameters), :id => nil %> - <%= @gantt.subjects.html_safe %> - <% end %> - <% end %> - <% end %> - - <% - style = "position: relative;" - style += "height: #{t_height + 24}px;" - %> - <%= content_tag(:div, :style => style, :class => "gantt_#{column_name}_container gantt_selected_column_container") do %> - <% - style = "height: #{t_height}px;" - style += 'overflow: hidden;' - %> - <%= content_tag(:div, '', :style => style, :class => "gantt_hdr") %> - <% - style = "height: #{headers_height}px;" - style += 'background: #f1f3f5;' # oc-gray-1 - %> - <%= content_tag(:div, content_tag(:p, column.caption, :class => 'gantt_hdr_selected_column_name'), :style => style, :class => "gantt_hdr") %> - <%= content_tag(:div, :class => "gantt_#{column_name} gantt_selected_column_content") do %> - <%= @gantt.selected_column_content({:column => column, :top => headers_height + 8, :zoom => zoom, :g_width => g_width}).html_safe %> - <% end %> - <% end %> - -
-<% - style = "" - style += "width: #{g_width - 1}px;" - style += "height: #{headers_height}px;" - style += 'background: #f1f3f5;' # oc-gray-1 -%> -<%= content_tag(:div, ' '.html_safe, :style => style, :class => "gantt_hdr") %> -<% ###### Months headers ###### %> -<% - month_f = @gantt.date_from - left = 0 - height = (show_weeks ? header_height : header_height + g_height) -%> -<% @gantt.months.times do %> - <% - width = (((month_f >> 1) - month_f) * zoom - 1).to_i - style = "" - style += "left: #{left}px;" - style += "width: #{width}px;" - style += "height: #{height}px;" - %> - <%= content_tag(:div, :style => style, :class => "gantt_hdr") do %> - <%= link_to "#{month_f.year}-#{month_f.month}", - @gantt.params.merge(:year => month_f.year, :month => month_f.month), - :title => "#{month_name(month_f.month)} #{month_f.year}" %> - <% end %> - <% - left = left + width + 1 - month_f = month_f >> 1 - %> -<% end %> - -<% ###### Weeks headers ###### %> -<% if show_weeks %> - <% - left = 0 - height = (show_days ? header_height - 1 : header_height - 1 + g_height) - %> - <% if @gantt.date_from.cwday == 1 %> - <% - # @date_from is monday - week_f = @gantt.date_from - %> - <% else %> - <% - # find next monday after @date_from - week_f = @gantt.date_from + (7 - @gantt.date_from.cwday + 1) - width = (7 - @gantt.date_from.cwday + 1) * zoom - 1 - style = "" - style += "left: #{left}px;" - style += "top: 19px;" - style += "width: #{width}px;" - style += "height: #{height}px;" - %> - <%= content_tag(:div, ' '.html_safe, - :style => style, :class => "gantt_hdr") %> - <% left = left + width + 1 %> - <% end %> - <% while week_f <= @gantt.date_to %> - <% - width = ((week_f + 6 <= @gantt.date_to) ? - 7 * zoom - 1 : - (@gantt.date_to - week_f + 1) * zoom - 1).to_i - style = "" - style += "left: #{left}px;" - style += "top: 19px;" - style += "width: #{width}px;" - style += "height: #{height}px;" - %> - <%= content_tag(:div, :style => style, :class => "gantt_hdr") do %> - <%= content_tag(:small) do %> - <%= week_f.cweek if width >= 16 %> - <% end %> - <% end %> - <% - left = left + width + 1 - week_f = week_f + 7 - %> - <% end %> -<% end %> - -<% ###### Day numbers headers ###### %> -<% if show_day_num %> - <% - left = 0 - height = g_height + header_height*2 - 1 - wday = @gantt.date_from.cwday - day_num = @gantt.date_from - %> - <% (@gantt.date_to - @gantt.date_from + 1).to_i.times do %> - <% - width = zoom - 1 - style = "" - style += "left:#{left}px;" - style += "top:37px;" - style += "width:#{width}px;" - style += "height:#{height}px;" - style += "font-size:0.7em;" - clss = +"gantt_hdr" - clss << " nwday" if @gantt.non_working_week_days.include?(wday) - %> - <%= content_tag(:div, :style => style, :class => clss) do %> - <%= day_num.day %> - <% end %> - <% - left = left + width+1 - day_num = day_num + 1 - wday = wday + 1 - wday = 1 if wday > 7 - %> - <% end %> -<% end %> - -<% ###### Days headers ####### %> -<% if show_days %> - <% - left = 0 - height = g_height + header_height - 1 - top = (show_day_num ? 55 : 37) - %> - <% (@gantt.date_from..@gantt.date_to).each do |g_date| %> - <% - width = zoom - 1 - style = "" - style += "left: #{left}px;" - style += "top: #{top}px;" - style += "width: #{width}px;" - style += "height: #{height}px;" - style += "font-size:0.7em;" - clss = +"gantt_hdr" - clss << " nwday" if @gantt.non_working_week_days.include?(g_date.cwday) - %> - <%= content_tag(:div, :style => style, :class => clss) do %> - <%= day_letter(g_date.cwday) %> - <% end %> - <% - left = left + width + 1 - %> - <% end %> -<% end %> - -<%= form_tag({}, :data => {:cm_url => issues_context_menu_path}) do -%> - <%= hidden_field_tag 'back_url', url_for(:params => request.query_parameters), :id => nil %> - <%= @gantt.lines.html_safe %> -<% end %> - -<% ###### Today red line (excluded from cache) ###### %> -<% if User.current.today >= @gantt.date_from and User.current.today <= @gantt.date_to %> - <% - today_left = (((User.current.today - @gantt.date_from + 1) * zoom).floor() - 1).to_i - style = "" - style += "position: absolute;" - style += "height: #{g_height}px;" - style += "top: #{headers_height + 1}px;" - style += "left: #{today_left}px;" - style += "width:10px;" - style += "border-left: 1px dashed red;" - %> - <%= content_tag(:div, ' '.html_safe, :style => style, :id => 'today_line') %> -<% end %> -<% - style = "" - style += "position: absolute;" - style += "height: #{g_height}px;" - style += "top: #{headers_height + 1}px;" - style += "left: 0px;" - style += "width: #{g_width - 1}px;" -%> -<%= content_tag(:div, '', :style => style, :id => "gantt_draw_area") %> -
-
- - - - - -<% other_formats_links do |f| %> - <%= f.link_to_with_query_parameters 'PDF', @gantt.params %> - <%= f.link_to_with_query_parameters('PNG', @gantt.params) if @gantt.respond_to?('to_image') %> +<% if @query.valid? %> +<%= render partial: 'chart', locals: {gantt: @gantt, query: @query} %> <% end %> -<% end # query.valid? %> <% content_for :sidebar do %> - <%= render :partial => 'issues/sidebar' %> + <%= render partial: 'issues/sidebar' %> <% end %> -<% html_title(l(:label_gantt)) -%> +<% html_title l(:label_gantt) %> <% content_for :header_tags do %> + <%= stylesheet_link_tag 'gantt', media: 'all' %> <%= javascript_include_tag 'raphael' %> - <%= javascript_include_tag 'gantt' %> <% end %> -<%= javascript_tag do %> - var issue_relation_type = <%= raw Redmine::Helpers::Gantt::DRAW_TYPES.to_json %>; - $(function() { - disable_unavailable_columns('<%= Redmine::Helpers::Gantt::UNAVAILABLE_COLUMNS.map(&:to_s).join(',') %>'.split(',')); - drawGanttHandler(); - resizableSubjectColumn(); - drawSelectedColumns(); - $("#draw_relations, #draw_progress_line, #draw_selected_columns").change(drawGanttHandler); - $('div.gantt_subjects .expander').on('click', ganttEntryClick); - }); - $(window).resize(function() { - drawGanttHandler(); - resizableSubjectColumn(); - }); -<% end %> <%= context_menu %> diff --git a/lib/redmine/helpers/gantt.rb b/lib/redmine/helpers/gantt.rb index f6c18ff54..4df1f034d 100644 --- a/lib/redmine/helpers/gantt.rb +++ b/lib/redmine/helpers/gantt.rb @@ -816,7 +816,10 @@ module Redmine } end if has_children - content = view.content_tag(:span, view.sprite_icon('angle-down').html_safe, :class => 'icon icon-expanded expander') + content + content = view.content_tag(:span, + view.sprite_icon('angle-down').html_safe, + :class => 'icon icon-expanded expander', + :data => {:action => 'click->gantt--subjects#handleEntryClick'}) + content tag_options[:class] += ' open' else if params[:indent] diff --git a/test/system/gantt_test.rb b/test/system/gantts_test.rb similarity index 60% rename from test/system/gantt_test.rb rename to test/system/gantts_test.rb index 0f897f65a..1b18caaf3 100644 --- a/test/system/gantt_test.rb +++ b/test/system/gantts_test.rb @@ -2,7 +2,7 @@ require_relative '../application_system_test_case' -class GanttSystemTest < ApplicationSystemTestCase +class GanttsTest < ApplicationSystemTestCase setup do log_user('jsmith', 'jsmith') end @@ -11,33 +11,33 @@ class GanttSystemTest < ApplicationSystemTestCase visit_gantt expand_options - assert_no_selector('td#status', visible: :visible) - assert_no_selector('td#priority', visible: :visible) - assert_no_selector('td#assigned_to', visible: :visible) - assert_no_selector('td#updated_on', visible: :visible) + assert_no_selector 'td#status' + assert_no_selector 'td#priority' + assert_no_selector 'td#assigned_to' + assert_no_selector 'td#updated_on' find('#draw_selected_columns').check - assert_selector('div.gantt_subjects_container.draw_selected_columns') - assert_selector('td#status', visible: :visible) - assert_selector('td#priority', visible: :visible) - assert_selector('td#assigned_to', visible: :visible) - assert_selector('td#updated_on', visible: :visible) + assert_selector 'div.gantt_subjects_container.draw_selected_columns' + assert_selector 'td#status' + assert_selector 'td#priority' + assert_selector 'td#assigned_to' + assert_selector 'td#updated_on' end test 'related issues toggle displays and hides relation arrows' do visit_gantt expand_options - assert_selector('#gantt_draw_area path', minimum: 1) + assert_selector '#gantt_draw_area path', minimum: 1 find('#draw_relations').uncheck - assert_no_selector('#gantt_draw_area path') + assert_no_selector '#gantt_draw_area path' find('#draw_relations').check - assert_selector('#gantt_draw_area path', minimum: 1) + assert_selector '#gantt_draw_area path', minimum: 1 end test 'progress line toggle draws zigzag line' do @@ -45,11 +45,11 @@ class GanttSystemTest < ApplicationSystemTestCase expand_options find('#draw_relations').uncheck - assert_no_selector('#gantt_draw_area path') + assert_no_selector '#gantt_draw_area path' find('#draw_progress_line').check - assert_selector('#gantt_draw_area path', minimum: 1) + assert_selector '#gantt_draw_area path', minimum: 1 end test 'selected columns can be resized by dragging' do @@ -73,20 +73,20 @@ class GanttSystemTest < ApplicationSystemTestCase task_area = find('div.tooltip.hascontextmenu', match: :first, visible: :all) task_area.hover - assert_selector('div.tooltip span.tip', text: issue_reference, visible: :visible) + assert_selector 'div.tooltip span.tip', text: issue_reference issue_subject.right_click - assert_selector('#context-menu', visible: :visible) - assert_selector('#context-menu a.icon-edit', visible: :visible) + assert_selector '#context-menu' + assert_selector '#context-menu a.icon-edit' page.send_keys(:escape) task_area = find('div.tooltip.hascontextmenu', match: :first, visible: :all) task_area.right_click - assert_selector('#context-menu', visible: :visible) - assert_selector('#context-menu a.icon-edit', visible: :visible) + assert_selector '#context-menu' + assert_selector '#context-menu a.icon-edit' page.send_keys(:escape) end @@ -107,7 +107,7 @@ class GanttSystemTest < ApplicationSystemTestCase end def drag_column_resizer(column_id, distance) - handle = find("td##{column_id} .ui-resizable-e", visible: :visible) + handle = find("td##{column_id} .ui-resizable-e") page.driver.browser.action.click_and_hold(handle.native).move_by(distance, 0).release.perform end end -- 2.51.0