From c374fcce8979de0c93f52d6efa4c2735493f3885 Mon Sep 17 00:00:00 2001 From: Katsuya HIDAKA Date: Thu, 24 Sep 2026 00:47:01 +0900 Subject: Organize Gantt chart DOM structure and semantics --- app/assets/stylesheets/context_menu.css | 6 +- app/assets/stylesheets/gantt.css | 100 ++++----- app/assets/stylesheets/responsive.css | 4 +- app/helpers/gantt_helper.rb | 16 +- .../controllers/gantt/chart_controller.js | 48 ++--- .../controllers/gantt/column_controller.js | 17 +- .../controllers/gantt/subjects_controller.js | 167 +++++---------- app/views/gantts/_chart.html.erb | 50 +++-- lib/redmine/helpers/gantt.rb | 195 +++++++++--------- test/functional/gantts_controller_test.rb | 73 +++---- test/system/gantts_test.rb | 53 +++-- test/unit/lib/redmine/helpers/gantt_test.rb | 138 +++++++------ 12 files changed, 398 insertions(+), 469 deletions(-) diff --git a/app/assets/stylesheets/context_menu.css b/app/assets/stylesheets/context_menu.css index 9225c19ca..b802ad8ad 100644 --- a/app/assets/stylesheets/context_menu.css +++ b/app/assets/stylesheets/context_menu.css @@ -46,6 +46,6 @@ .context-menu-selection a, .context-menu-selection a:hover { color:var(--oc-gray-0) !important; } .context-menu-selection:hover { background-color:var(--oc-blue-7) !important; color:var(--oc-gray-0) !important; } .context-menu-selection svg.icon-svg { stroke: var(--oc-white) !important; } -div#gantt_area .context-menu-selection { background-color: rgba(var(--oc-blue-7-rgb), 0.4) !important; } -div#gantt_area .context-menu-selection:hover { background-color: rgba(var(--oc-blue-7-rgb), 0.4) !important; } -div#gantt_area .context-menu-selection a { color: var(--oc-blue-9) !important; } +.gantt-timeline .context-menu-selection { background-color: rgba(var(--oc-blue-7-rgb), 0.4) !important; } +.gantt-timeline .context-menu-selection:hover { background-color: rgba(var(--oc-blue-7-rgb), 0.4) !important; } +.gantt-timeline .context-menu-selection a { color: var(--oc-blue-9) !important; } diff --git a/app/assets/stylesheets/gantt.css b/app/assets/stylesheets/gantt.css index 606de081b..32e280d66 100644 --- a/app/assets/stylesheets/gantt.css +++ b/app/assets/stylesheets/gantt.css @@ -4,17 +4,16 @@ * This code is released under the GNU General Public License. */ -.gantt_subjects div > span.expander { +[data-gantt-column="subjects"] .gantt-row > span.expander { margin-inline-start: 0; cursor: pointer; } -.gantt_subjects .avatar { +[data-gantt-column="subjects"] .avatar { margin-inline-end: 4px; } -.gantt_subjects div.project-name a, -.gantt_subjects div.version-name a { +[data-gantt-column="subjects"] .gantt-row:is([data-gantt-row-type="project"], [data-gantt-row-type="version"]) a { margin-inline-start: 4px; } @@ -33,8 +32,7 @@ display: none !important; } -.gantt_subjects_column, -.gantt_selected_column { +.gantt-column { position: relative; flex: 0 0 var(--gantt-column-width); inline-size: var(--gantt-column-width); @@ -53,11 +51,11 @@ overflow: hidden; } -.gantt_subjects_container { +[data-gantt-column="subjects"] .gantt-pane { border-inline-start: 1px solid var(--oc-gray-4); } -.gantt-pane-header { +.gantt-pane > header { position: relative; z-index: 2; display: flex; @@ -71,7 +69,7 @@ overflow: hidden; } -.gantt-selected-column-header { +.gantt-selected-column .gantt-pane > header { padding-inline: 3px; text-overflow: ellipsis; white-space: nowrap; @@ -80,6 +78,7 @@ .gantt-pane-body { position: relative; block-size: var(--gantt-content-height); + font-size: 0.8em; } .gantt-timeline { @@ -101,7 +100,7 @@ box-sizing: border-box; } -.gantt-timeline-header { +.gantt-timeline-canvas > header { position: relative; z-index: 1; block-size: var(--gantt-headers-height); @@ -149,8 +148,7 @@ pointer-events: auto; } -.gantt-period-day, -.gantt-period-day-number { +.gantt-header-row:is([data-gantt-period="day"], [data-gantt-period="day-number"]) .gantt-period { font-size: 0.7em; } @@ -168,15 +166,7 @@ block-size: var(--gantt-content-height); } -.gantt_subjects, -.gantt_selected_column_content { - position: relative; - block-size: 100%; - font-size: 0.8em; -} - -.gantt_subjects form, -.gantt_selected_column_content, +.gantt-pane-body form, .gantt-timeline-body form { position: relative; display: block; @@ -191,15 +181,15 @@ box-sizing: border-box; } -.gantt-subject-row { +[data-gantt-column="subjects"] .gantt-row { inset-inline-start: var(--gantt-row-indent); - inline-size: var(--gantt-row-width); + inline-size: calc(100% - var(--gantt-row-indent)); line-height: 16px; overflow: hidden; white-space: nowrap; } -.gantt-column-row { +.gantt-selected-column .gantt-row { inset-inline-start: 0; inline-size: 100%; padding-inline-start: 3px; @@ -208,16 +198,16 @@ white-space: nowrap; } -.gantt_subjects div.issue-subject:hover { +[data-gantt-column="subjects"] .gantt-row[data-gantt-row-type="issue"]:hover { background-color: var(--oc-yellow-0); } -.gantt-timeline-row { +.gantt-timeline-body .gantt-row { inset-inline-start: 0; inline-size: var(--gantt-chart-width); } -#today_line { +.gantt-today { position: absolute; inset-block-start: 1px; inset-inline-start: var(--gantt-today-start); @@ -227,7 +217,7 @@ pointer-events: none; } -#gantt_draw_area { +.gantt-relations { position: absolute; inset-block-start: 1px; inset-inline-start: 0; @@ -236,24 +226,24 @@ pointer-events: none; } -.gantt_watcher_users_column div.issue_watcher_users ul { +[data-gantt-column="watcher_users"] div.issue_watcher_users ul { margin: 0; padding: 0; list-style: none; } -.gantt_watcher_users_column div.issue_watcher_users ul li { +[data-gantt-column="watcher_users"] div.issue_watcher_users ul li { display: inline; } -.gantt_watcher_users_column div.issue_watcher_users ul li:not(:last-child)::after { +[data-gantt-column="watcher_users"] div.issue_watcher_users ul li:not(:last-child)::after { content: ', '; white-space: pre; } -.task { +.gantt-task { position: absolute; - inset-block-start: var(--gantt-row-top); + inset-block-start: 0; inset-inline-start: var(--gantt-task-start); inline-size: var(--gantt-task-width); block-size: 8px; @@ -267,57 +257,51 @@ .gantt-timeline-body .tooltip { position: absolute; - inset-block-start: var(--gantt-row-top); + inset-block-start: 0; inset-inline-start: var(--gantt-task-start); inline-size: var(--gantt-task-width); block-size: 12px; } -.gantt-row > :is(.task, .tooltip) { - inset-block-start: 0; -} - .gantt-chart .toggle-selection { display: none; } -.task.label { +.gantt-task-label { inline-size: var(--gantt-task-width); } -.task.label.project, -.task.label.version { +.gantt-row:is([data-gantt-row-type="project"], [data-gantt-row-type="version"]) .gantt-task-label { font-weight: bold; } -.task_late { +.gantt-task-late { background: var(--oc-red-5) url(/task_late.png); border: 1px solid var(--oc-red-5); } -.task_done { +.gantt-task-done { background: var(--oc-green-7) url(/task_done.png); border: 1px solid var(--oc-green-7); } -.task_todo { +.gantt-task-todo { background: var(--oc-gray-5) url(/task_todo.png); border: 1px solid var(--oc-gray-5); } -.task_todo.parent { +.gantt-task-todo.gantt-task-parent { background: var(--oc-gray-6); border: 1px solid var(--oc-gray-6); block-size: 3px; } -.task_late.parent, -.task_done.parent { +.gantt-task-late.gantt-task-parent, +.gantt-task-done.gantt-task-parent { block-size: 3px; } -.task.parent.marker.starting, -.task.parent.marker.ending { +.gantt-task.gantt-task-parent.gantt-task-marker { position: absolute; background: url(/task_parent_end.png) no-repeat 0 0; inline-size: 8px; @@ -325,28 +309,28 @@ margin-inline-start: -4px; } -.version.task_late { +.gantt-row[data-gantt-row-type="version"] .gantt-task-late { background: var(--oc-red-5) url(/milestone_late.png); border: 1px solid var(--oc-red-5); block-size: 2px; margin-block-start: 3px; } -.version.task_done { +.gantt-row[data-gantt-row-type="version"] .gantt-task-done { background: var(--oc-green-7) url(/milestone_done.png); border: 1px solid var(--oc-green-7); block-size: 2px; margin-block-start: 3px; } -.version.task_todo { +.gantt-row[data-gantt-row-type="version"] .gantt-task-todo { background: var(--oc-white) url(/milestone_todo.png); border: 1px solid var(--oc-white); block-size: 2px; margin-block-start: 3px; } -.version.marker { +.gantt-row[data-gantt-row-type="version"] .gantt-task-marker { background-image: url(/version_marker.png); background-repeat: no-repeat; border: 0; @@ -354,28 +338,28 @@ margin-block-start: 1px; } -.project.task_late { +.gantt-row[data-gantt-row-type="project"] .gantt-task-late { background: var(--oc-red-5) url(/milestone_late.png); border: 1px solid var(--oc-red-5); block-size: 2px; margin-block-start: 3px; } -.project.task_done { +.gantt-row[data-gantt-row-type="project"] .gantt-task-done { background: var(--oc-green-7) url(/milestone_done.png); border: 1px solid var(--oc-green-7); block-size: 2px; margin-block-start: 3px; } -.project.task_todo { +.gantt-row[data-gantt-row-type="project"] .gantt-task-todo { background: var(--oc-white) url(/milestone_todo.png); border: 1px solid var(--oc-white); block-size: 2px; margin-block-start: 3px; } -.project.marker { +.gantt-row[data-gantt-row-type="project"] .gantt-task-marker { background-image: url(/project_marker.png); background-repeat: no-repeat; border: 0; @@ -384,7 +368,7 @@ } /* RTL adjustment (disabled while Gantt is forced LTR). Enable when RTL is supported. -[dir="rtl"] :is(.marker, .task.parent.marker) { + [dir="rtl"] .gantt-task-marker { background-position: right; } */ diff --git a/app/assets/stylesheets/responsive.css b/app/assets/stylesheets/responsive.css index 77616f72d..baa3854e3 100644 --- a/app/assets/stylesheets/responsive.css +++ b/app/assets/stylesheets/responsive.css @@ -667,11 +667,11 @@ } /* Gantt charts */ - .gantt_subjects_column { + .gantt-column[data-gantt-column="subjects"] { --gantt-column-width: 60% !important; } - .gantt_subjects_container { + [data-gantt-column="subjects"] .gantt-pane { inline-size: 100% !important; overflow: hidden; } diff --git a/app/helpers/gantt_helper.rb b/app/helpers/gantt_helper.rb index 9dfa72369..f482cd316 100644 --- a/app/helpers/gantt_helper.rb +++ b/app/helpers/gantt_helper.rb @@ -231,24 +231,16 @@ module GanttHelper end def gantt_column_tag(column_name, min_width: nil, **options, &) - options[:data] = { + options[:data] = options.fetch(:data, {}).merge( 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]] + 'gantt-column': column_name + ) + options[:class] = ['gantt-column', options[:class]] options[:style] = gantt_css_variables('gantt-column-width': options.delete(:width)) if options[:width] tag.div(**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 index 33bae5fcc..1e57c41b7 100644 --- a/app/javascript/controllers/gantt/chart_controller.js +++ b/app/javascript/controllers/gantt/chart_controller.js @@ -4,7 +4,7 @@ const RELATION_STROKE_WIDTH = 2 const SVG_NS = "http://www.w3.org/2000/svg" export default class extends Controller { - static targets = ["ganttArea", "drawArea", "subjectsContainer"] + static targets = ["relations", "selectedColumn", "today"] static values = { issueRelationTypes: Object, @@ -88,8 +88,8 @@ export default class extends Controller { } #setupDrawPaper() { - const width = Math.ceil(this.$(this.drawAreaTarget).width() || 0) - const height = Math.ceil(this.$(this.drawAreaTarget).height() || 0) + const width = Math.ceil(this.$(this.relationsTarget).width() || 0) + const height = Math.ceil(this.$(this.relationsTarget).height() || 0) if (!this.#drawPaper) { this.#drawPaper = document.createElementNS(SVG_NS, "svg") @@ -100,7 +100,7 @@ export default class extends Controller { this.#drawPaperGroup = document.createElementNS(SVG_NS, "g") this.#drawPaper.appendChild(this.#drawPaperGroup) - this.drawAreaTarget.appendChild(this.#drawPaper) + this.relationsTarget.appendChild(this.#drawPaper) } const safeWidth = Math.max(width, 1) @@ -124,45 +124,36 @@ export default class extends Controller { } #setupDrawArea() { - const $drawArea = this.$(this.drawAreaTarget) + const $drawArea = this.$(this.relationsTarget) this.#drawTop = $drawArea.position().top this.#drawRight = $drawArea.width() } #drawSelectedColumns() { - const selectedColumns = this.element.querySelectorAll(".gantt_selected_column") - const $subjectsContainer = this.$(".gantt_subjects_container") - const isMobileDevice = typeof window.isMobile === "function" && window.isMobile() if (this.showSelectedColumnsValue) { if (isMobileDevice) { - selectedColumns.forEach((element) => { element.hidden = true }) + this.selectedColumnTargets.forEach((element) => { element.hidden = true }) } else { - $subjectsContainer.addClass("draw_selected_columns") - selectedColumns.forEach((element) => { element.hidden = false }) + this.selectedColumnTargets.forEach((element) => { element.hidden = false }) } } else { - selectedColumns.forEach((element) => { element.hidden = true }) - $subjectsContainer.removeClass("draw_selected_columns") + this.selectedColumnTargets.forEach((element) => { element.hidden = true }) } } get #relationsArray() { const relations = [] - this.$("div.task_todo[data-rels]").each((_, element) => { + this.$(".gantt-task-todo[data-gantt-relations]").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") || {} + const issueId = element.dataset.ganttIssueId + const dataRels = JSON.parse(element.dataset.ganttRelations || "{}") Object.keys(dataRels).forEach((relTypeKey) => { this.$.each(dataRels[relTypeKey], (_, relatedIssue) => { @@ -178,8 +169,8 @@ export default class extends Controller { 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}`) + const issueFrom = this.$(`.gantt-task-todo[data-gantt-issue-id='${relation.issue_from}']`) + const issueTo = this.$(`.gantt-task-todo[data-gantt-issue-id='${relation.issue_to}']`) if (issueFrom.length === 0 || issueTo.length === 0) return if (!issueTo.is(":visible")) return @@ -305,11 +296,11 @@ export default class extends Controller { get #progressLinesArray() { const lines = [] - const todayLeft = this.$("#today_line").position().left + const todayLeft = this.$(this.todayTarget).position().left lines.push({ left: todayLeft, top: 0 }) - this.$("div.issue-subject, div.version-name").each((_, element) => { + this.$("[data-gantt-column='subjects'] .gantt-row[data-gantt-row-type='issue'], [data-gantt-column='subjects'] .gantt-row[data-gantt-row-type='version']").each((_, element) => { const $element = this.$(element) if (!$element.is(":visible")) return true @@ -325,7 +316,8 @@ export default class extends Controller { if (issueClosed || versionClosed) { lines.push({ left: todayLeft, top: elementTopCenter }) } else { - const issueDone = this.$(`#task-done-${$element.attr("id")}`) + const rowKey = element.dataset.ganttRowKey + const issueDone = this.$(`.gantt-row[data-gantt-row-key='${rowKey}'] .gantt-task-done`) const isBehindStart = $element.children("span").hasClass("behind-start-date") const isOverEnd = $element.children("span").hasClass("over-end-date") @@ -350,7 +342,7 @@ export default class extends Controller { }) } else { let todoLeft = todayLeft - const issueTodo = this.$(`#task-todo-${$element.attr("id")}`) + const issueTodo = this.$(`.gantt-row[data-gantt-row-key='${rowKey}'] .gantt-task-todo`) if (issueTodo.length > 0) { todoLeft = issueTodo.first().position().left } @@ -363,10 +355,10 @@ export default class extends Controller { } #drawGanttProgressLines() { - if (this.$("#today_line").length === 0) return + if (!this.hasTodayTarget) return const progressLines = this.#progressLinesArray - const color = this.$("#today_line").css("border-inline-start-color") || "#ff0000" + const color = this.$(this.todayTarget).css("border-inline-start-color") || "#ff0000" for (let index = 1; index < progressLines.length; index += 1) { const current = progressLines[index] diff --git a/app/javascript/controllers/gantt/column_controller.js b/app/javascript/controllers/gantt/column_controller.js index 2c4bf7679..2bc4a1d15 100644 --- a/app/javascript/controllers/gantt/column_controller.js +++ b/app/javascript/controllers/gantt/column_controller.js @@ -3,7 +3,6 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { minWidth: Number, - column: String, // Local value mobileMode: { type: Boolean, default: false } } @@ -17,7 +16,6 @@ export default class extends Controller { connect() { this.#$element = this.$(this.element) this.#setupResizable() - this.#dispatchResizeColumn() } disconnect() { @@ -27,8 +25,6 @@ export default class extends Controller { handleWindowResize(_event) { this.mobileModeValue = this.#isMobile() - - this.#dispatchResizeColumn() } mobileModeValueChanged(current, old) { @@ -52,18 +48,7 @@ export default class extends Controller { } } - 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() } }) + this.#$element.resizable(options) } #isMobile() { diff --git a/app/javascript/controllers/gantt/subjects_controller.js b/app/javascript/controllers/gantt/subjects_controller.js index 219d7cf12..411d935a8 100644 --- a/app/javascript/controllers/gantt/subjects_controller.js +++ b/app/javascript/controllers/gantt/subjects_controller.js @@ -1,138 +1,69 @@ 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) - }) - } + static targets = ["row"] handleEntryClick(event) { - const iconExpander = event.currentTarget - const $subject = this.$(iconExpander.parentElement) - const subjectInlineStart = - this.#readInlineStart($subject) + 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 .gantt-timeline-body form > div[data-collapse-expand='${json.obj_id}'][data-number-of-rows='${numberOfRows}']` - const selectedColumnsSelector = `.gantt_selected_column div[data-collapse-expand='${json.obj_id}'][data-number-of-rows='${numberOfRows}']` - - if (outOfHierarchy || this.#readInlineStart($element) <= subjectInlineStart) { - outOfHierarchy = true - - if (targetShown === null) return false - - const newTopVal = this.#readBlockStart($element) + totalHeight * (targetShown ? -1 : 1) - - this.#setBlockStart($element, newTopVal) - this.$([barsSelector, selectedColumnsSelector].join()).each((__, el) => { - this.#setBlockStart(this.$(el), newTopVal) - }) - - return true - } - - const isShown = $element.is(":visible") - - if (targetShown === null) { - targetShown = isShown - targetTop = this.#readBlockStart($element) - totalHeight = 0 - } - - if (isShown === targetShown) { - this.$(barsSelector).each((__, task) => { - const $task = this.$(task) - - if (!isShown && willOpen) { - this.#setBlockStart($task, targetTop + totalHeight) - } - if (!$task.hasClass("tooltip")) { - $task.toggle(willOpen) - } - }) - - this.$(selectedColumnsSelector).each((__, attr) => { - const $attr = this.$(attr) - - if (!isShown && willOpen) { - this.#setBlockStart($attr, targetTop + totalHeight) - } - $attr.toggle(willOpen) - }) - - if (!isShown && willOpen) { - this.#setBlockStart($element, targetTop + totalHeight) - } + const subject = event.currentTarget.closest(".gantt-row") + if (!subject) return + + const chart = this.element.closest(".gantt-chart") + const subjectRows = this.rowTargets + const subjectKey = subject.dataset.ganttRowKey + const descendantKeys = new Set([subjectKey]) + const descendants = [] + const willOpen = !subject.classList.contains("is-expanded") + + subjectRows.forEach((row) => { + if (row === subject) return + if (!descendantKeys.has(row.dataset.ganttParentRowKey)) return + + descendantKeys.add(row.dataset.ganttRowKey) + descendants.push(row) + }) - this.#setIconState($element, willOpen) - $element.toggle(willOpen) - totalHeight += parseInt(json.top_increment, 10) - } + this.#setIconState(subject, willOpen) + descendants.forEach((row) => { + this.#setIconState(row, willOpen) + chart.querySelectorAll(this.#rowSelector(row.dataset.ganttRowKey)).forEach((matchingRow) => { + matchingRow.hidden = !willOpen + }) }) + this.#positionVisibleRows(chart, subjectRows) + this.dispatch("toggle-tree", { bubbles: true }) } - #readInlineStart(el) { - const node = el.jquery ? el[0] : el - return parseFloat(window.getComputedStyle(node).getPropertyValue("inset-inline-start")) - } + #positionVisibleRows(chart, subjectRows) { + const chartStyle = window.getComputedStyle(chart) + const contentTop = parseFloat(chartStyle.getPropertyValue("--gantt-content-top")) || 0 + const rowHeight = parseFloat(window.getComputedStyle(subjectRows[0]).blockSize) || 20 + let top = contentTop - #readBlockStart(el) { - const node = el.jquery ? el[0] : el - return parseFloat(window.getComputedStyle(node).getPropertyValue("inset-block-start")) + subjectRows.forEach((row) => { + if (row.hidden) return + + chart.querySelectorAll(this.#rowSelector(row.dataset.ganttRowKey)).forEach((matchingRow) => { + matchingRow.style.setProperty("--gantt-row-top", `${top}px`) + }) + top += rowHeight + }) } - #setBlockStart(el, value) { - const node = el.jquery ? el[0] : el - const px = typeof value === "number" ? `${value}px` : value - node.style.setProperty("--gantt-row-top", px) + #rowSelector(rowKey) { + return `.gantt-row[data-gantt-row-key="${CSS.escape(rowKey)}"]` } #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") - } - } + const expander = element.querySelector(".expander") + if (!expander) return + + element.classList.toggle("is-expanded", open) + expander.classList.toggle("icon-expanded", open) + expander.classList.toggle("icon-collapsed", !open) + if (expander.querySelectorAll("svg").length === 1) { + window.updateSVGIcon(expander, open ? "angle-down" : "angle-right") } } } diff --git a/app/views/gantts/_chart.html.erb b/app/views/gantts/_chart.html.erb index e61fbf598..3677cd6a0 100644 --- a/app/views/gantts/_chart.html.erb +++ b/app/views/gantts/_chart.html.erb @@ -14,15 +14,12 @@ <%= gantt_chart_tag(@query, layout) do %> <%= gantt_column_tag('subjects', min_width: 100, width: 'var(--gantt-subject-width)') do %> -
- -
- <%= 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 %> +
+ +
+ <%= 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 %>
@@ -33,23 +30,24 @@ 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, width: '50px', id: column_name, - class: 'gantt_selected_column', hidden: !query.draw_selected_columns) do %> -
-
<%= column.caption %>
-
+ <%= gantt_column_tag(column_name, min_width: 20, width: '50px', class: 'gantt-selected-column', + hidden: !query.draw_selected_columns, + data: {'gantt--chart-target': 'selectedColumn'}) do %> +
+
<%= column.caption %>
+
<%= gantt.selected_column_content(column: column, top: layout.content_top, zoom: layout.zoom, g_width: layout.chart_width).html_safe %>
<% end %> <% end %> -
+
-
-
+
+
<% layout.months.each do |period| %> -
<%= link_to "#{period.date.year}-#{period.date.month}", gantt.params.merge(year: period.date.year, month: period.date.month), @@ -59,9 +57,9 @@
<% if layout.show_weeks? %> -
+
<% layout.weeks.each do |period| %> -
<%= period.date.cweek if period.date && period.width >= 16 %>
@@ -70,18 +68,18 @@ <% end %> <% if layout.show_day_numbers? %> -
+
<% layout.day_numbers.each do |period| %> -
<%= period.date.day %>
<% end %>
<% end %> <% if layout.show_days? %> -
+
<% layout.days.each do |period| %> -
<%= day_letter(period.date.cwday) %>
<% end %>
@@ -95,10 +93,10 @@ <% end %> <% if layout.today_start %> -
" aria-hidden="true">
+
" aria-hidden="true">
<% end %> - +
diff --git a/lib/redmine/helpers/gantt.rb b/lib/redmine/helpers/gantt.rb index a8dc27212..46a2cc289 100644 --- a/lib/redmine/helpers/gantt.rb +++ b/lib/redmine/helpers/gantt.rb @@ -230,7 +230,11 @@ module Redmine begin Project.project_tree(projects) do |project, level| options[:indent] = indent + level * options[:indent_increment] - render_project(project, options) + parent_row_key = + if level > 0 && project.parent + gantt_project_row_key(project.parent) + end + render_project(project, options, parent_row_key: parent_row_key) end rescue MaxLinesLimitReached @truncated = true @@ -240,12 +244,13 @@ module Redmine render_end(options) end - def render_project(project, options={}) - render_object_row(project, options) + def render_project(project, options={}, parent_row_key: nil) + row = gantt_project_row(project, parent_row_key: parent_row_key) + render_object_row(project, options, row: row) increment_indent(options) do # render issue that are not assigned to a version issues = project_issues(project).select {|i| i.fixed_version_id.nil?} - render_issues(issues, options) + render_issues(issues, options, parent_row_key: row[:gantt_row_key]) # then render project versions and their issues versions = project_versions(project) self.class.sort_versions!(versions) @@ -256,14 +261,15 @@ module Redmine end def render_version(project, version, options={}) - render_object_row(version, options) + row = gantt_version_row(version, project) + render_object_row(version, options, row: row) increment_indent(options) do issues = version_issues(project, version) - render_issues(issues, options) + render_issues(issues, options, parent_row_key: row[:gantt_row_key]) end end - def render_issues(issues, options={}) + def render_issues(issues, options={}, parent_row_key: nil) self.class.sort_issues!(issues) ancestors = [] issues.each do |issue| @@ -271,7 +277,10 @@ module Redmine ancestors.pop decrement_indent(options) end - render_object_row(issue, options) + issue_parent_row_key = + ancestors.any? ? gantt_issue_row_key(ancestors.last) : parent_row_key + row = gantt_issue_row(issue, parent_row_key: issue_parent_row_key) + render_object_row(issue, options, row: row) unless issue.leaf? ancestors << issue increment_indent(options) @@ -280,14 +289,24 @@ module Redmine decrement_indent(options, ancestors.size) end - def render_object_row(object, options) + def render_object_row(object, options, row:) class_name = object.class.name.downcase - send(:"subject_for_#{class_name}", object, options) unless options[:only] == :lines || options[:only] == :selected_columns + subject_options = + if options[:format] == :html + options.merge(:gantt_row => row) + else + options + end + send(:"subject_for_#{class_name}", object, subject_options) unless options[:only] == :lines || options[:only] == :selected_columns unless options[:only] == :subjects || options[:only] == :selected_columns line = send(:"line_for_#{class_name}", object, options) - @lines << html_gantt_row(object, options, line) if options[:format] == :html + if options[:format] == :html + @lines << html_gantt_row(options, line, row: row) + end + end + if options[:only] == :selected_columns && options[:column].present? + column_content_for_object(object, options, row: row) end - column_content_for_object(object, options) if options[:only] == :selected_columns && options[:column].present? options[:top] += options[:top_increment] @number_of_rows += 1 if @max_rows && @number_of_rows >= @max_rows @@ -302,6 +321,44 @@ module Redmine end end + def gantt_project_row_key(project) + "project-#{project.id}" + end + + def gantt_version_row_key(version, project) + "version-#{version.id}-project-#{project.id}" + end + + def gantt_issue_row_key(issue) + "issue-#{issue.id}" + end + + def gantt_project_row(project, parent_row_key: nil) + row = { + :gantt_row_key => gantt_project_row_key(project), + :gantt_row_type => 'project' + } + row[:gantt_parent_row_key] = parent_row_key if parent_row_key + row + end + + def gantt_version_row(version, project) + { + :gantt_row_key => gantt_version_row_key(version, project), + :gantt_row_type => 'version', + :gantt_parent_row_key => gantt_project_row_key(project) + } + end + + def gantt_issue_row(issue, parent_row_key: nil) + row = { + :gantt_row_key => gantt_issue_row_key(issue), + :gantt_row_type => 'issue' + } + row[:gantt_parent_row_key] = parent_row_key if parent_row_key + row + end + def increment_indent(options, factor=1) options[:indent] += options[:indent_increment] * factor if block_given? @@ -357,24 +414,15 @@ module Redmine end end - def column_content_for_object(object, options) + def column_content_for_object(object, options, row:) if options[:format] == :html - data_options = { - :collapse_expand => "#{object.class}-#{object.id}".downcase, - :number_of_rows => number_of_rows - } value = object.is_a?(Issue) ? view.column_content(options[:column], object) : nil content = view.content_tag( :div, value, :style => css_variables('gantt-row-top': "#{options[:top] || 0}px"), - :class => [ - "gantt-row", - "gantt-column-row", - ("issue_#{options[:column].name}" if object.is_a?(Issue)) - ], - :id => ("#{options[:column].name}_issue_#{object.id}" if object.is_a?(Issue)), - :data => data_options + :class => 'gantt-row', + :data => row ) @columns[options[:column].name] << content if @columns.has_key?(options[:column].name) content @@ -382,7 +430,7 @@ module Redmine end def column_content_for_issue(issue, options) - column_content_for_object(issue, options) + column_content_for_object(issue, options, row: gantt_issue_row(issue)) end def subject(label, options, object=nil) @@ -797,11 +845,10 @@ module Redmine def html_subject(params, subject, object) content = html_subject_content(object) || subject - tag_options = {} + tag_options = {:class => []} case object when Issue - tag_options[:id] = "issue-#{object.id}" - tag_options[:class] = "issue-subject hascontextmenu" + tag_options[:class] << 'hascontextmenu' tag_options[:title] = object.subject has_children = if object.leaf? @@ -812,42 +859,33 @@ module Redmine children.any? {|child| child.fixed_version_id == fixed_version_id} end when Version - tag_options[:id] = "version-#{object.id}" - tag_options[:class] = "version-name" has_children = object.fixed_issues.exists? when Project - tag_options[:class] = "project-name" has_children = object.issues.exists? || object.versions.exists? end - if object - tag_options[:data] = { - :collapse_expand => { - :top_increment => params[:top_increment], - :obj_id => "#{object.class}-#{object.id}".downcase, - }, - :number_of_rows => number_of_rows, - } - end if has_children content = view.content_tag(:span, view.sprite_icon('angle-down', rtl: true).html_safe, :class => 'icon icon-expanded expander', :data => {:action => 'click->gantt--subjects#handleEntryClick'}) + content - tag_options[:class] += ' open' + tag_options[:class] << 'is-expanded' else if params[:indent] params = params.dup params[:indent] += 18 end end - tag_options[:class] = [tag_options[:class], 'gantt-row', 'gantt-subject-row'].compact.join(' ') + tag_options[:class] << 'gantt-row' + tag_options[:class] = tag_options[:class].join(' ') + if object + tag_options[:data] = params[:gantt_row].to_h.merge( + 'gantt--subjects-target': 'row' + ) + end row_variables = { 'gantt-row-top': "#{params[:top] || 0}px", 'gantt-row-indent': "#{params[:indent]}px" } - if params[:subject_width] - row_variables['gantt-row-width'] = "#{params[:subject_width] - params[:indent]}px" - end tag_options[:style] = css_variables(row_variables) output = view.content_tag(:div, content, tag_options) @subjects << output @@ -889,58 +927,35 @@ module Redmine def html_task(params, coords, markers, label, object) output = +'' - data_options = {} - if object - data_options[:collapse_expand] = "#{object.class}-#{object.id}".downcase - data_options[:number_of_rows] = number_of_rows - end - css = "task " + - case object - when Project - "project" - when Version - "version" - when Issue - object.leaf? ? 'leaf' : 'parent' - else - "" - end + task_classes = ['gantt-task'] + task_classes << 'gantt-task-parent' if object.is_a?(Issue) && !object.leaf? # Renders the task bar, with progress and late if coords[:bar_start] && coords[:bar_end] width = coords[:bar_end] - coords[:bar_start] - 2 style = task_style(coords[:bar_start], width) - html_id = "task-todo-issue-#{object.id}" if object.is_a?(Issue) - html_id = "task-todo-version-#{object.id}" if object.is_a?(Version) - content_opt = {:style => style, - :class => "#{css} task_todo", - :id => html_id, - :data => {}} + todo_data = {} if object.is_a?(Issue) + todo_data['gantt-issue-id'] = object.id rels = issue_relations(object) - if rels.present? - content_opt[:data] = {"rels" => rels.to_json} - end + todo_data['gantt-relations'] = rels.to_json if rels.present? end - content_opt[:data].merge!(data_options) + content_opt = {:style => style, + :class => (task_classes + ['gantt-task-todo']).join(' '), + :data => todo_data} output << view.content_tag(:div, ' '.html_safe, content_opt) if coords[:bar_late_end] width = coords[:bar_late_end] - coords[:bar_start] - 2 style = task_style(coords[:bar_start], width) output << view.content_tag(:div, ' '.html_safe, :style => style, - :class => "#{css} task_late", - :data => data_options) + :class => (task_classes + ['gantt-task-late']).join(' ')) end if coords[:bar_progress_end] width = coords[:bar_progress_end] - coords[:bar_start] - 2 style = task_style(coords[:bar_start], width) - html_id = "task-done-issue-#{object.id}" if object.is_a?(Issue) - html_id = "task-done-version-#{object.id}" if object.is_a?(Version) output << view.content_tag(:div, ' '.html_safe, :style => style, - :class => "#{css} task_done", - :id => html_id, - :data => data_options) + :class => (task_classes + ['gantt-task-done']).join(' ')) end end # Renders the markers @@ -949,15 +964,13 @@ module Redmine style = task_style(coords[:start], 15) output << view.content_tag(:div, ' '.html_safe, :style => style, - :class => "#{css} marker starting", - :data => data_options) + :class => (task_classes + ['gantt-task-marker', 'gantt-task-start']).join(' ')) end if coords[:end] style = task_style(coords[:end], 15) output << view.content_tag(:div, ' '.html_safe, :style => style, - :class => "#{css} marker ending", - :data => data_options) + :class => (task_classes + ['gantt-task-marker', 'gantt-task-end']).join(' ')) end end # Renders the label on the right @@ -965,38 +978,32 @@ module Redmine style = task_style((coords[:bar_end] || 0) + 8, 15) output << view.content_tag(:div, label, :style => style, - :class => "#{css} label", - :data => data_options) + :class => (task_classes + ['gantt-task-label']).join(' ')) end # Renders the tooltip if object.is_a?(Issue) && coords[:bar_start] && coords[:bar_end] s = view.content_tag(:span, view.render_issue_tooltip(object).html_safe, - :class => "tip") + :class => 'tip') s += view.content_tag(:input, nil, :type => 'checkbox', :name => 'ids[]', :value => object.id, :class => 'toggle-selection') style = task_style(coords[:bar_start], coords[:bar_end] - coords[:bar_start]) output << view.content_tag(:div, s.html_safe, :style => style, - :class => "tooltip hascontextmenu", - :data => data_options) + :class => 'tooltip hascontextmenu') end output end - def html_gantt_row(object, params, content) - data_options = { - :collapse_expand => "#{object.class}-#{object.id}".downcase, - :number_of_rows => number_of_rows - } + def html_gantt_row(params, content, row:) style = css_variables('gantt-row-top': "#{params[:top] || 0}px") view.content_tag( :div, content.to_s.html_safe, - :class => 'gantt-row gantt-timeline-row', + :class => 'gantt-row', :style => style, - :data => data_options + :data => row ) end diff --git a/test/functional/gantts_controller_test.rb b/test/functional/gantts_controller_test.rb index 0cdf371f9..cbccca8d0 100644 --- a/test/functional/gantts_controller_test.rb +++ b/test/functional/gantts_controller_test.rb @@ -56,7 +56,7 @@ class GanttsControllerTest < Redmine::ControllerTest end # Assert context menu on issues subject and gantt bar - assert_select 'div.issue-subject.hascontextmenu' + assert_select 'div.gantt-column[data-gantt-column=subjects] div.gantt-row[data-gantt-row-type=issue].hascontextmenu' assert_select 'div.tooltip.hascontextmenu' do assert_select 'img[class="gravatar avatar"]' end @@ -155,8 +155,8 @@ class GanttsControllerTest < Redmine::ControllerTest get :show assert_response :success - assert_select 'div.task_todo[id=?][data-rels*=?]', "task-todo-issue-#{issue1.id}", issue2.id.to_s - assert_select 'div.task_todo[id=?]:not([data-rels])', "task-todo-issue-#{issue2.id}" + assert_select 'div.gantt-task-todo[data-gantt-issue-id=?][data-gantt-relations*=?]', issue1.id.to_s, issue2.id.to_s + assert_select 'div.gantt-task-todo[data-gantt-issue-id=?]:not([data-gantt-relations])', issue2.id.to_s end def test_gantt_should_export_to_pdf @@ -223,7 +223,7 @@ class GanttsControllerTest < Redmine::ControllerTest } ) assert_response :success - assert_select 'div.gantt-period-month>a', :text => /^[\d-]+$/, :count => 40 + assert_select 'div.gantt-header-row[data-gantt-period=month] .gantt-period>a', :text => /^[\d-]+$/, :count => 40 # Displays 6 months (the default value for `months`) if `months` exceeds # gant_months_limit @@ -236,7 +236,7 @@ class GanttsControllerTest < Redmine::ControllerTest } ) assert_response :success - assert_select 'div.gantt-period-month>a', :text => /^[\d-]+$/, :count => 6 + assert_select 'div.gantt-header-row[data-gantt-period=month] .gantt-period>a', :text => /^[\d-]+$/, :count => 6 end end @@ -251,46 +251,46 @@ class GanttsControllerTest < Redmine::ControllerTest assert_response :success # eCookbook - assert_subject_row('div.project-name', row: '0', text: project.name) - assert_chart_row('div.task.project.task_todo', row: '0', style_substring: 'inset-inline-start:0px;width:138px') + assert_subject_row('project-1', row: '0', text: project.name) + assert_chart_row('div.gantt-task-todo', row: '0', style_substring: 'inset-inline-start:0px;width:138px') assert_issue_row(3, 'Bug #3', row: '1') - assert_chart_row('div.task.leaf.task_todo', row: '1', style_substring: 'inset-inline-start:0px;width:38px') + assert_chart_row('div.gantt-task-todo', row: '1', style_substring: 'inset-inline-start:0px;width:38px') assert_issue_row(7, 'Bug #7', row: '2') - assert_chart_row('div.task.leaf.task_todo', row: '2', style_substring: 'inset-inline-start:16px;width:42px') + assert_chart_row('div.gantt-task-todo', row: '2', style_substring: 'inset-inline-start:16px;width:42px') assert_issue_row(1, 'Bug #1', row: '3') - assert_chart_row('div.task.leaf.task_todo', row: '3', style_substring: 'inset-inline-start:52px;width:46px') + assert_chart_row('div.gantt-task-todo', row: '3', style_substring: 'inset-inline-start:52px;width:46px') # Version 1.0 - assert_subject_row('div#version-2', row: '4', text: '1.0') - assert_chart_row('div.task.version', row: '4', style_substring: 'inset-inline-start:48px;width:90px') + assert_subject_row('version-2-project-1', row: '4', text: '1.0') + assert_chart_row('div.gantt-task-todo', row: '4', style_substring: 'inset-inline-start:48px;width:90px') assert_issue_row(2, 'Feature request #2', row: '5') - assert_chart_row('div.task.leaf.task_todo', row: '5', style_substring: 'inset-inline-start:48px;width:90px') + assert_chart_row('div.gantt-task-todo', row: '5', style_substring: 'inset-inline-start:48px;width:90px') # Private child of eCookbook assert_subject_row( - 'div.project-name[data-collapse-expand*="project-5"]', + 'project-5', row: '6', text: projects(:projects_005).name ) - assert_chart_row('div.task.project.task_todo', row: '6', style_substring: 'inset-inline-start:56px;width:6px') + assert_chart_row('div.gantt-task-todo', row: '6', style_substring: 'inset-inline-start:56px;width:6px') assert_issue_row(6, 'Bug #6', row: '7') - assert_chart_row('div.task.leaf.task_todo', row: '7', style_substring: 'inset-inline-start:56px;width:6px') + assert_chart_row('div.gantt-task-todo', row: '7', style_substring: 'inset-inline-start:56px;width:6px') assert_issue_row(9, 'Bug #9', row: '8') - assert_chart_row('div.task.leaf.task_todo', row: '8', style_substring: 'inset-inline-start:56px;width:6px') + assert_chart_row('div.gantt-task-todo', row: '8', style_substring: 'inset-inline-start:56px;width:6px') assert_issue_row(10, 'Bug #10', row: '9') - assert_chart_row('div.task.leaf.task_todo', row: '9', style_substring: 'inset-inline-start:56px;width:6px') - assert_select 'div.task[id=?][data-rels*=9]', 'task-todo-issue-10' + assert_chart_row('div.gantt-task-todo', row: '9', style_substring: 'inset-inline-start:56px;width:6px') + assert_select 'div.gantt-task-todo[data-gantt-issue-id=?][data-gantt-relations*=?]', '10', '9' # eCookbook Subproject1 assert_subject_row( - 'div.project-name[data-collapse-expand*="project-3"]', + 'project-3', row: '10', text: projects(:projects_003).name ) @@ -322,34 +322,34 @@ class GanttsControllerTest < Redmine::ControllerTest 6.times do |offset| m = selected_start.since(offset.month) - assert_select 'div.gantt-period-month > a', text: "#{m.year}-#{m.month}" + assert_select 'div.gantt-header-row[data-gantt-period=month] .gantt-period > a', text: "#{m.year}-#{m.month}" end # eCookbook - assert_subject_row('div.project-name', row: '0', text: projects(:projects_001).name) - assert_chart_row('div.task.project.task_todo', row: '0', style_substring: 'inset-inline-start:0px;width:258px') + assert_subject_row('project-1', row: '0', text: projects(:projects_001).name) + assert_chart_row('div.gantt-task-todo', row: '0', style_substring: 'inset-inline-start:0px;width:258px') # Private child of eCookbook assert_subject_row( - 'div.project-name[data-collapse-expand*="project-5"]', + 'project-5', row: '1', text: project.name ) - assert_chart_row('div.task.project.task_todo', row: '1', style_substring: 'inset-inline-start:176px;width:6px') + assert_chart_row('div.gantt-task-todo', row: '1', style_substring: 'inset-inline-start:176px;width:6px') # Bug #6 assert_issue_row(6, 'Bug #6', row: '2') - assert_chart_row('div.task.leaf.task_todo', row: '2', style_substring: 'inset-inline-start:176px;width:6px') + assert_chart_row('div.gantt-task-todo', row: '2', style_substring: 'inset-inline-start:176px;width:6px') # Bug #9 assert_issue_row(9, 'Bug #9', row: '3') - assert_chart_row('div.task.leaf.task_todo', row: '3', style_substring: 'inset-inline-start:176px;width:6px') + assert_chart_row('div.gantt-task-todo', row: '3', style_substring: 'inset-inline-start:176px;width:6px') # Bug #10 assert_issue_row(10, 'Bug #10', row: '4') - assert_chart_row('div.task.leaf.task_todo', row: '4', style_substring: 'inset-inline-start:176px;width:6px') + assert_chart_row('div.gantt-task-todo', row: '4', style_substring: 'inset-inline-start:176px;width:6px') - assert_select 'div.task[id=?][data-rels*=9]', 'task-todo-issue-10' + assert_select 'div.gantt-task-todo[data-gantt-issue-id=?][data-gantt-relations*=?]', '10', '9' end test 'shows six months starting from current month' do @@ -366,7 +366,7 @@ class GanttsControllerTest < Redmine::ControllerTest 6.times do |offset| m = start_of_month.since(offset.months) - assert_select 'div.gantt-period-month > a', text: "#{m.year}-#{m.month}" + assert_select 'div.gantt-header-row[data-gantt-period=month] .gantt-period > a', text: "#{m.year}-#{m.month}" end assert_select 'input#months[value=?]', '6' @@ -377,23 +377,24 @@ class GanttsControllerTest < Redmine::ControllerTest private - def assert_subject_row(selector, row:, text:) - assert_select "div.gantt_subjects form #{selector}[data-number-of-rows=?]", row do + def assert_subject_row(row_key, row:, text:) + selector = "div.gantt-column[data-gantt-column=subjects] form > div.gantt-row:nth-of-type(#{row.to_i + 1})[data-gantt-row-key=?]" + assert_select selector, row_key do assert_select 'a', text: text end end def assert_issue_row(issue_id, link_text, row:) - selector = "div.gantt_subjects form div#issue-#{issue_id}[data-number-of-rows=\"#{row}\"]" - assert_select selector do + selector = "div.gantt-column[data-gantt-column=subjects] form > div.gantt-row:nth-of-type(#{row.to_i + 1})[data-gantt-row-key=?]" + assert_select selector, "issue-#{issue_id}" do assert_select 'a.issue', text: link_text end end def assert_chart_row(selector, row:, style_substring:) style_substring = "--gantt-task-start:#{style_substring.delete_prefix('inset-inline-start:').sub(';width:', ';--gantt-task-width:')}" - matcher = "#gantt_area #{selector}[data-number-of-rows=?][style*=?]" - assert_select matcher, row, style_substring, minimum: 1 + matcher = "div.gantt-timeline form > div.gantt-row:nth-of-type(#{row.to_i + 1}) #{selector}[style*=?]" + assert_select matcher, style_substring, minimum: 1 end # Freezes today and resets the start and due dates of issues and versions in the eCookbook project and its descendants to fixed values diff --git a/test/system/gantts_test.rb b/test/system/gantts_test.rb index 2eeb62790..1361d0489 100644 --- a/test/system/gantts_test.rb +++ b/test/system/gantts_test.rb @@ -11,18 +11,41 @@ class GanttsTest < ApplicationSystemTestCase visit_gantt expand_options - assert_no_selector 'div#status' - assert_no_selector 'div#priority' - assert_no_selector 'div#assigned_to' - assert_no_selector 'div#updated_on' + assert_no_selector 'div.gantt-column[data-gantt-column=status]' + assert_no_selector 'div.gantt-column[data-gantt-column=priority]' + assert_no_selector 'div.gantt-column[data-gantt-column=assigned_to]' + assert_no_selector 'div.gantt-column[data-gantt-column=updated_on]' find('#draw_selected_columns').check - assert_selector '.gantt_subjects_container.draw_selected_columns' - assert_selector 'div#status' - assert_selector 'div#priority' - assert_selector 'div#assigned_to' - assert_selector 'div#updated_on' + assert_selector 'div.gantt-column[data-gantt-column=subjects]' + assert_selector 'div.gantt-column[data-gantt-column=status]' + assert_selector 'div.gantt-column[data-gantt-column=priority]' + assert_selector 'div.gantt-column[data-gantt-column=assigned_to]' + assert_selector 'div.gantt-column[data-gantt-column=updated_on]' + end + + test 'tree toggle updates the same logical rows across every pane' do + visit_gantt + expand_options + find('#draw_selected_columns').check + + project_row = find('[data-gantt-column="subjects"] .gantt-row[data-gantt-row-key="project-1"]') + subject_issue = '[data-gantt-column="subjects"] .gantt-row[data-gantt-row-key="issue-3"]' + column_issue = '[data-gantt-column="status"] .gantt-row[data-gantt-row-key="issue-3"]' + timeline_issue = '.gantt-timeline-body .gantt-row[data-gantt-row-key="issue-3"]' + + project_row.find('.expander').click + + assert_selector subject_issue, visible: :hidden + assert_selector column_issue, visible: :hidden + assert_selector timeline_issue, visible: :hidden + + project_row.find('.expander').click + + assert_selector subject_issue, visible: :visible + assert_selector column_issue, visible: :visible + assert_selector timeline_issue, visible: :visible end test 'related issues toggle displays and hides relation arrows' do @@ -39,6 +62,7 @@ class GanttsTest < ApplicationSystemTestCase assert_selector '.gantt-relations path', minimum: 1 + # Relation arrows should keep the same position when redrawn after horizontal scrolling. paths_before_scroll = all('.gantt-relations path').pluck(:d) find('#draw_relations').uncheck @@ -76,14 +100,15 @@ class GanttsTest < ApplicationSystemTestCase test 'context menu and tooltip interactions' do visit_gantt - issue1_subject_row = find('#issue-1') - issue1_task_bar = find('div.tooltip[data-collapse-expand="issue-1"]') + issue1_subject_row = find('.gantt-column[data-gantt-column="subjects"] .gantt-row[data-gantt-row-key="issue-1"]') + issue1_task_bar = find('.gantt-timeline .gantt-row[data-gantt-row-key="issue-1"] .tooltip') # Tooltip for issue task bar issue1_task_bar.hover within issue1_task_bar do - assert_selector 'span.tip', text: issue1_subject_row.first('a.issue').text + issue_link_text = issue1_subject_row.first('a.issue', visible: :all).text + assert_selector '.tip', text: issue_link_text end # Context menu for issue subject @@ -115,11 +140,11 @@ class GanttsTest < ApplicationSystemTestCase end def column_width(id) - page.evaluate_script("document.querySelector('div##{id}').offsetWidth") + page.evaluate_script("document.querySelector('div.gantt-column[data-gantt-column=\"#{id}\"]').offsetWidth") end def drag_column_resizer(column_id, distance) - handle = find("div##{column_id} .ui-resizable-e") + handle = find("div.gantt-column[data-gantt-column=\"#{column_id}\"] .ui-resizable-e") page.driver.browser.action.click_and_hold(handle.native).move_by(distance, 0).release.perform end end diff --git a/test/unit/lib/redmine/helpers/gantt_test.rb b/test/unit/lib/redmine/helpers/gantt_test.rb index 7ebc24e9e..d01b12b1c 100644 --- a/test/unit/lib/redmine/helpers/gantt_test.rb +++ b/test/unit/lib/redmine/helpers/gantt_test.rb @@ -112,6 +112,19 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest assert_equal 3, @gantt.number_of_rows_on_project(@project) end + test "#gantt_version_row_key should distinguish a shared version rendered under different projects" do + create_gantt + version = Version.generate! + other_project = Project.generate! + + first_key = @gantt.send(:gantt_version_row_key, version, @project) + second_key = @gantt.send(:gantt_version_row_key, version, other_project) + + assert_equal "version-#{version.id}-project-#{@project.id}", first_key + assert_equal "version-#{version.id}-project-#{other_project.id}", second_key + assert_not_equal first_key, second_key + end + def setup_subjects create_gantt @project.enabled_module_names = [:issue_tracking] @@ -134,16 +147,15 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest test "#subjects project should be rendered" do setup_subjects @output_buffer = @gantt.subjects - assert_select "div.project-name a", /#{@project.name}/ - assert_select "div.project-name.gantt-row.gantt-subject-row" - assert_select 'div.project-name[style*="--gantt-row-indent:4px"]' + assert_select "div.gantt-row[data-gantt-row-type=project] a", /#{@project.name}/ + assert_select 'div.gantt-row[data-gantt-row-type=project][style*="--gantt-row-indent:4px"]' end test "#subjects version should be rendered" do setup_subjects @output_buffer = @gantt.subjects - assert_select "div.version-name a", /#{@version.name}/ - assert_select 'div.version-name[style*="--gantt-row-indent:24px"]' + assert_select "div.gantt-row[data-gantt-row-type=version] a", /#{@version.name}/ + assert_select 'div.gantt-row[data-gantt-row-type=version][style*="--gantt-row-indent:24px"]' end test "#subjects version without assigned issues should not be rendered" do @@ -153,15 +165,15 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest :name => 'empty_version') @project.versions << @version @output_buffer = @gantt.subjects - assert_select "div.version-name a", :text => /#{@version.name}/, :count => 0 + assert_select "div.gantt-row[data-gantt-row-type=version] a", :text => /#{@version.name}/, :count => 0 end test "#subjects issue should be rendered" do setup_subjects @output_buffer = @gantt.subjects - assert_select "div.issue-subject", /#{@issue.subject}/ + assert_select "div.gantt-row[data-gantt-row-type=issue]", /#{@issue.subject}/ # subject 62px: 44px + 18px(collapse/expand icon's width) - assert_select 'div.issue-subject[style*="--gantt-row-indent:62px"]' + assert_select 'div.gantt-row[data-gantt-row-type=issue][style*="--gantt-row-indent:62px"]' end test "#subjects issue assigned to a shared version of another project should be rendered" do @@ -180,7 +192,7 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest :due_date => (today + 7)) @project.issues << @issue @output_buffer = @gantt.subjects - assert_select "div.issue-subject", /#{@issue.subject}/ + assert_select "div.gantt-row[data-gantt-row-type=issue]", /#{@issue.subject}/ end test "#subjects issue with subtasks should indent subtasks" do @@ -206,13 +218,13 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest ) @output_buffer = @gantt.subjects # parent task 44px - assert_select 'div.issue-subject[style*="--gantt-row-indent:44px"]', /#{@issue.subject}/ + assert_select 'div.gantt-row[data-gantt-row-type=issue][style*="--gantt-row-indent:44px"]', /#{@issue.subject}/ # children 64px - assert_select 'div.issue-subject[style*="--gantt-row-indent:64px"]', /child1/ + assert_select 'div.gantt-row[data-gantt-row-type=issue][style*="--gantt-row-indent:64px"]', /child1/ # children 76px: 64px + 18px(collapse/expand icon's width) - assert_select 'div.issue-subject[style*="--gantt-row-indent:82px"]', /child2/ + assert_select 'div.gantt-row[data-gantt-row-type=issue][style*="--gantt-row-indent:82px"]', /child2/ # grandchild 96px: 84px + 18px(collapse/expand icon's width) - assert_select 'div.issue-subject[style*="--gantt-row-indent:102px"]', /grandchild/, @output_buffer + assert_select 'div.gantt-row[data-gantt-row-type=issue][style*="--gantt-row-indent:102px"]', /grandchild/, @output_buffer end test "#lines" do @@ -232,19 +244,19 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest @project.issues << @issue @output_buffer = @gantt.lines - assert_select "div.gantt-row.gantt-timeline-row", 3 - assert_select "div.project.task_todo" - assert_select "div.project.starting" - assert_select "div.project.ending" - assert_select "div.label.project", /#{@project.name}/ + assert_select "div.gantt-row[data-gantt-row-type=project]", 1 + assert_select "div.gantt-row[data-gantt-row-type=project] div.gantt-task-todo" + assert_select "div.gantt-row[data-gantt-row-type=project] div.gantt-task-start" + assert_select "div.gantt-row[data-gantt-row-type=project] div.gantt-task-end" + assert_select "div.gantt-row[data-gantt-row-type=project] div.gantt-task-label", /#{@project.name}/ - assert_select "div.version.task_todo" - assert_select "div.version.starting" - assert_select "div.version.ending" - assert_select "div.label.version", /#{@version.name}/ + assert_select "div.gantt-row[data-gantt-row-type=version] div.gantt-task-todo" + assert_select "div.gantt-row[data-gantt-row-type=version] div.gantt-task-start" + assert_select "div.gantt-row[data-gantt-row-type=version] div.gantt-task-end" + assert_select "div.gantt-row[data-gantt-row-type=version] div.gantt-task-label", /#{@version.name}/ - assert_select "div.task_todo" - assert_select "div.task.label", /#{@issue.done_ratio}/ + assert_select "div.gantt-row[data-gantt-row-type=issue] div.gantt-task-todo" + assert_select "div.gantt-row[data-gantt-row-type=issue] div.gantt-task-label", /#{@issue.done_ratio}/ assert_select "div.tooltip", /#{@issue.subject}/ end @@ -256,9 +268,11 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest @project.issues << issue # :column => assigned_to @output_buffer = @gantt.selected_column_content({:column => @gantt.query.columns.last}) - assert_select "div.gantt-row.gantt-column-row[data-collapse-expand='project-#{@project.id}']", :text => '' - assert_select "div.gantt-row.gantt-column-row", 2 - assert_select "div.issue_assigned_to#assigned_to_issue_#{issue.id}" + assert_select "div.gantt-row[data-gantt-row-key='project-#{@project.id}']", :text => '' + assert_select "div.gantt-row", 2 + assert_select "div.gantt-row[data-gantt-row-key='issue-#{issue.id}']" do + assert_select 'a.user', :text => issue.assignable_users.first.name + end end test "#lines keeps an empty row for an issue without dates" do @@ -268,8 +282,8 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest @output_buffer = @gantt.lines - assert_select "div.gantt-row.gantt-timeline-row[data-collapse-expand='issue-#{issue.id}']" do - assert_select ".task", 0 + assert_select "div.gantt-row[data-gantt-row-key='issue-#{issue.id}']" do + assert_select ".gantt-task", 0 end end @@ -343,7 +357,7 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest @project.stubs(:start_date).returns(today - 7) @project.stubs(:due_date).returns(today + 7) @output_buffer = @gantt.line_for_project(@project, :format => :html) - assert_select "div.project.label", :text => @project.name + assert_select "div.gantt-task-label", :text => @project.name end test "#line_for_version" do @@ -353,23 +367,23 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest version.stubs(:due_date).returns(today + 7) version.stubs(:visible_fixed_issues => stub(:completed_percent => 30)) @output_buffer = @gantt.line_for_version(version, :format => :html) - assert_select "div.version.label", :text => /Foo/ - assert_select "div.version.label", :text => /30%/ + assert_select "div.gantt-task-label", :text => /Foo/ + assert_select "div.gantt-task-label", :text => /30%/ end test "#line_for_issue" do create_gantt issue = Issue.generate!(:project => @project, :start_date => today - 7, :due_date => today + 7, :done_ratio => 30) @output_buffer = @gantt.line_for_issue(issue, :format => :html) - assert_select "div.task.label", :text => /#{issue.status.name}/ - assert_select "div.task.label", :text => /30%/ + assert_select "div.gantt-task-label", :text => /#{issue.status.name}/ + assert_select "div.gantt-task-label", :text => /30%/ assert_select "div.tooltip", /#{issue.subject}/ end test "#line todo line should start from the starting point on the left" do create_gantt @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4) - assert_select 'div.task_todo[style*="--gantt-task-start:28px"]', 1 + assert_select 'div.gantt-task-todo[style*="--gantt-task-start:28px"]', 1 end test "#line todo line should appear if it ends on the leftmost date in the gantt" do @@ -377,8 +391,8 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest [gantt_start - 1, gantt_start].each do |start_date| @output_buffer = @gantt.line(start_date, gantt_start, 30, false, 'line', :format => :html, :zoom => 4) # the leftmost date (Date.today - 14 days) - assert_select 'div.task_todo[style*="--gantt-task-start:0px"]', 1, @output_buffer - assert_select 'div.task_todo[style*="--gantt-task-width:2px"]', 1, @output_buffer + assert_select 'div.gantt-task-todo[style*="--gantt-task-start:0px"]', 1, @output_buffer + assert_select 'div.gantt-task-todo[style*="--gantt-task-width:2px"]', 1, @output_buffer end end @@ -387,112 +401,112 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest [gantt_end, gantt_end + 1].each do |end_date| @output_buffer = @gantt.line(gantt_end, end_date, 30, false, 'line', :format => :html, :zoom => 4) # the rightmost date (Date.today + 14 days) - assert_select 'div.task_todo[style*="--gantt-task-start:112px"]', 1, @output_buffer - assert_select 'div.task_todo[style*="--gantt-task-width:2px"]', 1, @output_buffer + assert_select 'div.gantt-task-todo[style*="--gantt-task-start:112px"]', 1, @output_buffer + assert_select 'div.gantt-task-todo[style*="--gantt-task-width:2px"]', 1, @output_buffer end end test "#line todo line should be the total width" do create_gantt @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4) - assert_select 'div.task_todo[style*="--gantt-task-width:58px"]', 1 + assert_select 'div.gantt-task-todo[style*="--gantt-task-width:58px"]', 1 end test "#line late line should start from the starting point on the left" do create_gantt @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4) - assert_select 'div.task_late[style*="--gantt-task-start:28px"]', 1 + assert_select 'div.gantt-task-late[style*="--gantt-task-start:28px"]', 1 end test "#line late line should be the total delayed width" do create_gantt @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4) - assert_select 'div.task_late[style*="--gantt-task-width:30px"]', 1 + assert_select 'div.gantt-task-late[style*="--gantt-task-width:30px"]', 1 end test "#line late line should be the same width as task_todo if start date and end date are the same day" do create_gantt @output_buffer = @gantt.line(today - 7, today - 7, 0, false, 'line', :format => :html, :zoom => 4) - assert_select 'div.task_late[style*="--gantt-task-width:2px"]', 1 - assert_select 'div.task_todo[style*="--gantt-task-width:2px"]', 1 + assert_select 'div.gantt-task-late[style*="--gantt-task-width:2px"]', 1 + assert_select 'div.gantt-task-todo[style*="--gantt-task-width:2px"]', 1 end test "#line late line should be the same width as task_todo if start date and today are the same day" do create_gantt @output_buffer = @gantt.line(today, today, 0, false, 'line', :format => :html, :zoom => 4) - assert_select 'div.task_late[style*="--gantt-task-width:2px"]', 1 - assert_select 'div.task_todo[style*="--gantt-task-width:2px"]', 1 + assert_select 'div.gantt-task-late[style*="--gantt-task-width:2px"]', 1 + assert_select 'div.gantt-task-todo[style*="--gantt-task-width:2px"]', 1 end test "#line done line should start from the starting point on the left" do create_gantt @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4) - assert_select 'div.task_done[style*="--gantt-task-start:28px"]', 1 + assert_select 'div.gantt-task-done[style*="--gantt-task-start:28px"]', 1 end test "#line done line should be the width for the done ratio" do create_gantt @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4) # 15 days * 4 px * 30% - 2 px for borders = 16 px - assert_select 'div.task_done[style*="--gantt-task-width:16px"]', 1 + assert_select 'div.gantt-task-done[style*="--gantt-task-width:16px"]', 1 end test "#line done line should be the total width for 100% done ratio" do create_gantt @output_buffer = @gantt.line(today - 7, today + 7, 100, false, 'line', :format => :html, :zoom => 4) # 15 days * 4 px - 2 px for borders = 58 px - assert_select 'div.task_done[style*="--gantt-task-width:58px"]', 1 + assert_select 'div.gantt-task-done[style*="--gantt-task-width:58px"]', 1 end test "#line done line should be the total width for 100% done ratio with same start and end dates" do create_gantt @output_buffer = @gantt.line(today + 7, today + 7, 100, false, 'line', :format => :html, :zoom => 4) - assert_select 'div.task_done[style*="--gantt-task-width:2px"]', 1 + assert_select 'div.gantt-task-done[style*="--gantt-task-width:2px"]', 1 end test "#line done line should not be the total done width if the gantt starts after start date" do create_gantt @output_buffer = @gantt.line(today - 16, today - 2, 30, false, 'line', :format => :html, :zoom => 4) - assert_select 'div.task_done[style*="--gantt-task-start:0px"]', 1 - assert_select 'div.task_done[style*="--gantt-task-width:8px"]', 1 + assert_select 'div.gantt-task-done[style*="--gantt-task-start:0px"]', 1 + assert_select 'div.gantt-task-done[style*="--gantt-task-width:8px"]', 1 end test "#line starting marker should appear at the start date" do create_gantt @output_buffer = @gantt.line(today - 7, today + 7, 30, true, 'line', :format => :html, :zoom => 4) - assert_select "div.starting", 1 - assert_select 'div.starting[style*="--gantt-task-start:28px"]', 1 + assert_select "div.gantt-task-start", 1 + assert_select 'div.gantt-task-start[style*="--gantt-task-start:28px"]', 1 # starting marker on the leftmost boundary of the gantt @output_buffer = @gantt.line(gantt_start, today + 7, 30, true, 'line', :format => :html, :zoom => 4) - assert_select 'div.starting[style*="--gantt-task-start:0px"]', 1 + assert_select 'div.gantt-task-start[style*="--gantt-task-start:0px"]', 1 end test "#line starting marker should not appear if the start date is before gantt start date" do create_gantt @output_buffer = @gantt.line(gantt_start - 2, today + 7, 30, true, 'line', :format => :html, :zoom => 4) - assert_select "div.starting", 0 + assert_select "div.gantt-task-start", 0 end test "#line ending marker should appear at the end date" do create_gantt @output_buffer = @gantt.line(today - 7, today + 7, 30, true, 'line', :format => :html, :zoom => 4) - assert_select "div.ending", 1 - assert_select 'div.ending[style*="--gantt-task-start:88px"]', 1 + assert_select "div.gantt-task-end", 1 + assert_select 'div.gantt-task-end[style*="--gantt-task-start:88px"]', 1 # ending marker on the rightmost boundary of the gantt @output_buffer = @gantt.line(today - 7, gantt_end, 30, true, 'line', :format => :html, :zoom => 4) - assert_select 'div.ending[style*="--gantt-task-start:116px"]', 1 + assert_select 'div.gantt-task-end[style*="--gantt-task-start:116px"]', 1 end test "#line ending marker should not appear if the end date is before gantt start date" do create_gantt @output_buffer = @gantt.line(gantt_start - 30, gantt_start - 21, 30, true, 'line', :format => :html) - assert_select "div.ending", 0 + assert_select "div.gantt-task-end", 0 end test "#line label should appear at the far left, even if it's before gantt start date" do create_gantt @output_buffer = @gantt.line(gantt_start - 30, gantt_start - 21, 30, true, 'line', :format => :html) - assert_select "div.label", :text => 'line' + assert_select "div.gantt-task-label", :text => 'line' end test "#column_content_for_issue" do @@ -505,7 +519,7 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest options = {:column => @gantt.query.columns.last, :top => 64, :format => :html} @output_buffer = @gantt.column_content_for_issue(issue, options) - assert_select "div.issue_assigned_to#assigned_to_issue_#{issue.id}" + assert_select "div.gantt-row[data-gantt-row-key='issue-#{issue.id}']" assert_includes @output_buffer, column_content(options[:column], issue) end -- 2.51.0