From 88d31dfd42c41848b11ed8b437ea8ff8a459e3d9 Mon Sep 17 00:00:00 2001 From: Katsuya HIDAKA Date: Thu, 24 Sep 2026 00:45:19 +0900 Subject: Refactor and modernize Gantt chart layout --- app/assets/stylesheets/gantt.css | 271 ++++++++++++++---- app/assets/stylesheets/responsive.css | 19 +- app/helpers/gantt_helper.rb | 193 ++++++++++++- .../controllers/gantt/chart_controller.js | 60 ++-- .../controllers/gantt/column_controller.js | 10 +- .../controllers/gantt/subjects_controller.js | 6 +- app/views/gantts/_chart.html.erb | 267 +++++------------ lib/redmine/helpers/gantt.rb | 113 +++++--- test/functional/gantts_controller_test.rb | 20 +- test/helpers/gantt_helper_test.rb | 178 ++++++++++++ test/system/gantts_test.rb | 42 +-- test/unit/lib/redmine/helpers/gantt_test.rb | 80 +++--- 12 files changed, 838 insertions(+), 421 deletions(-) create mode 100644 test/helpers/gantt_helper_test.rb diff --git a/app/assets/stylesheets/gantt.css b/app/assets/stylesheets/gantt.css index d41c3866b..606de081b 100644 --- a/app/assets/stylesheets/gantt.css +++ b/app/assets/stylesheets/gantt.css @@ -19,115 +19,243 @@ } /***** Gantt chart *****/ -table.gantt-table { +.gantt-chart { + display: flex; + align-items: flex-start; inline-size: 100%; - border-collapse: collapse; + margin-block-end: 1em; + overflow: hidden; /* Force LTR for Gantt even in RTL locales because column resizing, relation lines, and the progress line don't work in RTL yet. */ direction: ltr; } -table.gantt-table td { - padding: 0; +.gantt-chart [hidden] { + display: none !important; } -.gantt_hdr { - position: absolute; - inset-block-start: 0; - block-size: 16px; +.gantt_subjects_column, +.gantt_selected_column { + position: relative; + flex: 0 0 var(--gantt-column-width); + inline-size: var(--gantt-column-width); + min-inline-size: 0; +} + +.gantt-chart .ui-resizable-e { + cursor: ew-resize; +} + +.gantt-pane { + block-size: calc(var(--gantt-pane-height) + 2px); border-block: 1px solid var(--oc-gray-4); + border-inline-end: 1px solid var(--oc-gray-4); + box-sizing: border-box; + overflow: hidden; +} + +.gantt_subjects_container { border-inline-start: 1px solid var(--oc-gray-4); - text-align: center; +} + +.gantt-pane-header { + position: relative; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + block-size: var(--gantt-headers-height); + border-block-end: 1px solid var(--oc-gray-4); + box-sizing: border-box; + background: var(--oc-gray-1); + font-size: 0.8em; overflow: hidden; } -#gantt_area .gantt_hdr { - border-inline-start: 0; +.gantt-selected-column-header { + padding-inline: 3px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gantt-pane-body { + position: relative; + block-size: var(--gantt-content-height); +} + +.gantt-timeline { + position: relative; + flex: 1 1 auto; + min-inline-size: 0; + /* Keep the canvas border inside the overflow clipping edge. */ + padding-block-end: 1px; + overflow-x: auto; + overflow-y: hidden; +} + +.gantt-timeline-canvas { + position: relative; + inline-size: calc(var(--gantt-chart-width) + 1px); + block-size: calc(var(--gantt-pane-height) + 2px); + border-block: 1px solid var(--oc-gray-4); border-inline-end: 1px solid var(--oc-gray-4); + box-sizing: border-box; +} + +.gantt-timeline-header { + position: relative; + z-index: 1; + block-size: var(--gantt-headers-height); + border-block-end: 1px solid var(--oc-gray-4); + box-sizing: border-box; + background: var(--oc-gray-1); } -.gantt_subjects_container:not(.draw_selected_columns) .gantt_hdr, -.last_gantt_selected_column .gantt_hdr { +.gantt-header-row { + display: flex; + block-size: var(--gantt-header-height); +} + +.gantt-period { + position: relative; + flex: 0 0 var(--gantt-period-width); + inline-size: var(--gantt-period-width); + min-inline-size: 0; + block-size: 100%; border-inline-end: 1px solid var(--oc-gray-4); + border-block-end: 1px solid var(--oc-gray-4); + text-align: center; + box-sizing: border-box; + overflow: hidden; + white-space: nowrap; + pointer-events: none; } -.last_gantt_selected_column .gantt_selected_column_container, -.gantt_subjects_container .gantt_subjects * { - z-index: 10; +.gantt-header-row:last-child .gantt-period { + border-block-end: 0; } -.gantt_subjects_column + td { - padding: 0; +.gantt-period-last { + border-inline-end: 0; +} + +.gantt-period-spans-body { + block-size: calc(100% + var(--gantt-content-height)); + border-block-end: 0; +} + +.gantt-period a { + position: relative; + z-index: 1; + pointer-events: auto; +} + +.gantt-period-day, +.gantt-period-day-number { + font-size: 0.7em; } -.gantt_hdr.nwday { +.gantt-period-non-working { background-color: var(--oc-gray-1); color: var(--oc-gray-6); } +.gantt-timeline-body { + position: absolute; + z-index: 2; + inset-block-start: var(--gantt-headers-height); + inset-inline-start: 0; + inline-size: var(--gantt-chart-width); + block-size: var(--gantt-content-height); +} + .gantt_subjects, -.gantt_selected_column_content.gantt_hdr { +.gantt_selected_column_content { + position: relative; + block-size: 100%; font-size: 0.8em; +} + +.gantt_subjects form, +.gantt_selected_column_content, +.gantt-timeline-body form { position: relative; - z-index: 1; + display: block; + inline-size: 100%; + block-size: 100%; } -.gantt_subjects div, -.gantt_selected_column_content div { +.gantt-row { + position: absolute; + inset-block-start: var(--gantt-row-top); + block-size: 20px; + box-sizing: border-box; +} + +.gantt-subject-row { + inset-inline-start: var(--gantt-row-indent); + inline-size: var(--gantt-row-width); line-height: 16px; - block-size: 16px; overflow: hidden; white-space: nowrap; - text-overflow: clip; +} + +.gantt-column-row { + inset-inline-start: 0; inline-size: 100%; + padding-inline-start: 3px; + line-height: 16px; + overflow: hidden; + white-space: nowrap; } .gantt_subjects div.issue-subject:hover { background-color: var(--oc-yellow-0); } -.gantt_selected_column_content > div { - padding-inline-start: 3px; - box-sizing: border-box; +.gantt-timeline-row { + inset-inline-start: 0; + inline-size: var(--gantt-chart-width); } -.gantt_hdr_selected_column_name { +#today_line { position: absolute; - inset-block-start: 50%; - inline-size: 100%; - transform: translateY(-50%); - -webkit-transform: translateY(-50%); - font-size: 0.8em; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -td.gantt_selected_column { - inline-size: 50px; + inset-block-start: 1px; + inset-inline-start: var(--gantt-today-start); + inline-size: 10px; + block-size: var(--gantt-content-height); + border-inline-start: 1px dashed red; + pointer-events: none; } -td.gantt_selected_column .gantt_hdr, -.gantt_selected_column_container { - inline-size: 49px; +#gantt_draw_area { + position: absolute; + inset-block-start: 1px; + inset-inline-start: 0; + inline-size: var(--gantt-chart-width); + block-size: var(--gantt-content-height); + pointer-events: none; } -td.gantt_watcher_users_column div.issue_watcher_users ul { +.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 { +.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 { +.gantt_watcher_users_column div.issue_watcher_users ul li:not(:last-child)::after { content: ', '; white-space: pre; } .task { position: absolute; + inset-block-start: var(--gantt-row-top); + inset-inline-start: var(--gantt-task-start); + inline-size: var(--gantt-task-width); block-size: 8px; font-size: 0.8em; color: var(--oc-gray-6); @@ -137,8 +265,24 @@ td.gantt_watcher_users_column div.issue_watcher_users ul li:not(:last-child)::af white-space: nowrap; } +.gantt-timeline-body .tooltip { + position: absolute; + inset-block-start: var(--gantt-row-top); + 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 { - inline-size: 100%; + inline-size: var(--gantt-task-width); } .task.label.project, @@ -172,24 +316,13 @@ td.gantt_watcher_users_column div.issue_watcher_users ul li:not(:last-child)::af block-size: 3px; } -.task.parent.marker.starting { - position: absolute; - background: url(/task_parent_end.png) no-repeat 0 0; - inline-size: 8px; - block-size: 16px; - margin-inline-start: -4px; - inset-inline-start: 0; - inset-block-start: -1px; -} - +.task.parent.marker.starting, .task.parent.marker.ending { position: absolute; background: url(/task_parent_end.png) no-repeat 0 0; inline-size: 8px; block-size: 16px; margin-inline-start: -4px; - inset-inline-end: 0; - inset-block-start: -1px; } .version.task_late { @@ -266,3 +399,21 @@ td.gantt_watcher_users_column div.issue_watcher_users ul li:not(:last-child)::af .project-overdue a { color: var(--oc-red-8); } + +@media print { + /* Include the full timeline in the browser print layout. */ + body.controller-gantts.action-show #wrapper { + overflow: visible; + } + + .gantt-chart { + inline-size: max-content; + overflow: visible; + } + + .gantt-timeline { + flex: 0 0 calc(var(--gantt-chart-width) + 1px); + inline-size: calc(var(--gantt-chart-width) + 1px); + overflow: visible; + } +} diff --git a/app/assets/stylesheets/responsive.css b/app/assets/stylesheets/responsive.css index 440121c00..77616f72d 100644 --- a/app/assets/stylesheets/responsive.css +++ b/app/assets/stylesheets/responsive.css @@ -667,14 +667,8 @@ } /* Gantt charts */ - /* - * [1] override inline styles with important - * [2] keep border between subjects and gantt area - * [3] remove whitespace between subjects and gantt area - * [4] maintain width due to [3] - */ .gantt_subjects_column { - inline-size: 60% !important; /* [1] */ + --gantt-column-width: 60% !important; } .gantt_subjects_container { @@ -682,17 +676,6 @@ overflow: hidden; } - .gantt_subjects_column .gantt_hdr { - inline-size: 100% !important; - inset-inline-end: 0 !important; /* [2] */ - border-inline-end: solid 1px var(--oc-gray-4); - } - - #gantt_area { - inset-inline-start: -2px; /* [3] */ - margin-inline-end: -2px; /* [4] */ - } - /*----------------------------------------*\ G) FORMS \*----------------------------------------*/ diff --git a/app/helpers/gantt_helper.rb b/app/helpers/gantt_helper.rb index b86e3dbbf..40743fff1 100644 --- a/app/helpers/gantt_helper.rb +++ b/app/helpers/gantt_helper.rb @@ -18,6 +18,179 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. module GanttHelper + class ChartLayout + # A calendar period displayed in the timeline header. + Period = Struct.new( + # The first date of the period, or nil for a leading partial week + :date, + # The period width in pixels + :width, + # Whether the period represents a non-working day + :non_working, + # Whether the period's grid line extends through the chart body + :spans_body, + # Whether the period is the last one in its header row + :last, + keyword_init: true + ) + + # The height of one timeline header row in pixels + attr_reader :header_height + # The combined height of all visible header rows in pixels + attr_reader :headers_height + # The gap between the header and the first chart row in pixels + attr_reader :content_top + # The subject pane width used to render its rows in pixels + attr_reader :subject_width + # The number of pixels representing one day + attr_reader :zoom + + def initialize(gantt) + @gantt = gantt + @zoom = 2**gantt.zoom + @subject_width = 330 + @header_height = 18 + @content_top = 8 + @headers_height = header_rows * header_height + end + + # The full timeline width in pixels + def chart_width + @chart_width ||= begin + days_in_range = (@gantt.date_to - @gantt.date_from + 1).to_i + days_in_range * zoom + end + end + + def content_height + @content_height ||= begin + row_height = 20 + extra_rows = 6 + bottom_padding = 150 + minimum_height = 206 + + # Preserve the extra space and minimum height from the original template. + rows_height = row_height * (@gantt.number_of_rows + extra_rows) + [rows_height + bottom_padding, minimum_height].max + end + end + + def pane_height + headers_height + content_height + end + + def months + date = @gantt.date_from + + Array.new(@gantt.months) do |index| + next_month = date.next_month + days_in_month = (next_month - date).to_i + period = Period.new( + date: date, + width: days_in_month * zoom, + spans_body: !show_weeks?, + last: index == @gantt.months - 1 + ) + date = next_month + period + end + end + + def weeks + return [] unless show_weeks? + + date = @gantt.date_from + periods = [] + unless date.monday? + week_end = date.end_of_week(:monday) + remaining_days = (week_end - date + 1).to_i + # A leading partial week has no week-number label. + periods << Period.new( + date: nil, + width: remaining_days * zoom, + spans_body: !show_days?, + last: week_end >= @gantt.date_to + ) + date = date.next_week(:monday) + end + + while date <= @gantt.date_to + week_end = [date.end_of_week(:monday), @gantt.date_to].min + days_in_week = (week_end - date + 1).to_i + periods << Period.new( + date: date, + width: days_in_week * zoom, + spans_body: !show_days?, + last: week_end == @gantt.date_to + ) + date = date.next_week(:monday) + end + periods + end + + def day_numbers + return [] unless show_day_numbers? + + day_periods + end + + def days + return [] unless show_days? + + day_periods(spans_body: true) + end + + def today_start + today = User.current.today + return unless today.between?(@gantt.date_from, @gantt.date_to) + + days_through_today = (today - @gantt.date_from + 1).to_i + # Place the line at the last pixel of the current day. + days_through_today * zoom - 1 + end + + def show_weeks? + @gantt.zoom > 1 + end + + def show_days? + @gantt.zoom > 2 + end + + def show_day_numbers? + @gantt.zoom > 3 + end + + private + + def header_rows + # The month header is always displayed. + 1 + [show_weeks?, show_days?, show_day_numbers?].count(true) + end + + def day_periods(spans_body: false) + (@gantt.date_from..@gantt.date_to).map do |date| + Period.new( + date: date, + width: zoom, + non_working: @gantt.non_working_week_days.include?(date.cwday), + spans_body: spans_body, + last: date == @gantt.date_to + ) + end + end + end + + def gantt_css_variables(variables) + variables.map {|name, value| "--#{name}:#{value}"}.join(';') + end + + def gantt_period_style(period) + gantt_css_variables( + 'gantt-period-width': "#{period.width}px" + ) + end + def gantt_zoom_link(gantt, in_or_out) case in_or_out when :in @@ -42,7 +215,7 @@ module GanttHelper end end - def gantt_chart_tag(query, &) + def gantt_chart_tag(query, layout, &block) data_attributes = { controller: 'gantt--chart', # Events emitted by child controllers the chart listens to. @@ -62,7 +235,19 @@ module GanttHelper 'gantt--chart-show-progress-value': query.draw_progress_line ? 'true' : 'false' } - tag.table(class: 'gantt-table', data: data_attributes, &) + style = gantt_css_variables( + 'gantt-subject-width': "#{layout.subject_width + 1}px", + 'gantt-header-height': "#{layout.header_height}px", + 'gantt-headers-height': "#{layout.headers_height}px", + 'gantt-chart-width': "#{layout.chart_width}px", + 'gantt-content-top': "#{layout.content_top}px", + 'gantt-content-height': "#{layout.content_height}px", + 'gantt-pane-height': "#{layout.pane_height}px" + ) + + tag.div(class: 'gantt-chart', style: style, data: data_attributes) do + capture(layout, &block) + end end def gantt_column_tag(column_name, min_width: nil, **options, &) @@ -74,7 +259,9 @@ module GanttHelper } options[:class] = ["gantt_#{column_name}_column", options[:class]] - tag.td(**options, &) + options[:style] = gantt_css_variables('gantt-column-width': options.delete(:width)) if options[:width] + + tag.div(**options, &) end def gantt_subjects_tag(&) diff --git a/app/javascript/controllers/gantt/chart_controller.js b/app/javascript/controllers/gantt/chart_controller.js index 70a39195d..33bae5fcc 100644 --- a/app/javascript/controllers/gantt/chart_controller.js +++ b/app/javascript/controllers/gantt/chart_controller.js @@ -15,7 +15,6 @@ export default class extends Controller { #drawTop = 0 #drawRight = 0 - #drawLeft = 0 #drawPaper = null #drawPaperGroup = null @@ -26,7 +25,6 @@ export default class extends Controller { connect() { this.#drawTop = 0 this.#drawRight = 0 - this.#drawLeft = 0 this.#drawProgressLineAndRelations() this.#drawSelectedColumns() @@ -127,32 +125,26 @@ export default class extends Controller { #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 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.each((_, element) => { - this.$(element).hide() - }) + selectedColumns.forEach((element) => { element.hidden = true }) } else { $subjectsContainer.addClass("draw_selected_columns") - $selectedColumns.show() + selectedColumns.forEach((element) => { element.hidden = false }) } } else { - $selectedColumns.each((_, element) => { - this.$(element).hide() - }) + selectedColumns.forEach((element) => { element.hidden = true }) $subjectsContainer.removeClass("draw_selected_columns") } } @@ -193,9 +185,9 @@ export default class extends Controller { if (!issueTo.is(":visible")) return const issueHeight = issueFrom.height() - const issueFromTop = issueFrom.position().top + issueHeight / 2 - this.#drawTop + const issueFromTop = this.#taskTop(issueFrom) + issueHeight / 2 - this.#drawTop const issueFromRight = issueFrom.position().left + issueFrom.width() - const issueToTop = issueTo.position().top + issueHeight / 2 - this.#drawTop + const issueToTop = this.#taskTop(issueTo) + issueHeight / 2 - this.#drawTop const issueToLeft = issueTo.position().left const relationConfig = this.issueRelationTypesValue[relation.rel_type] || {} const color = relationConfig.color || "#000" @@ -206,10 +198,10 @@ export default class extends Controller { this.#drawPath( [ "M", - issueFromRight + this.#drawLeft, + issueFromRight, issueFromTop, "L", - issueFromRightRel + this.#drawLeft, + issueFromRightRel, issueFromTop ], { stroke: color, "stroke-width": RELATION_STROKE_WIDTH, fill: "none" } @@ -219,10 +211,10 @@ export default class extends Controller { this.#drawPath( [ "M", - issueFromRightRel + this.#drawLeft, + issueFromRightRel, issueFromTop, "L", - issueFromRightRel + this.#drawLeft, + issueFromRightRel, issueToTop ], { stroke: color, "stroke-width": RELATION_STROKE_WIDTH, fill: "none" } @@ -230,10 +222,10 @@ export default class extends Controller { this.#drawPath( [ "M", - issueFromRightRel + this.#drawLeft, + issueFromRightRel, issueToTop, "L", - issueToLeft + this.#drawLeft, + issueToLeft, issueToTop ], { stroke: color, "stroke-width": RELATION_STROKE_WIDTH, fill: "none" } @@ -243,10 +235,10 @@ export default class extends Controller { this.#drawPath( [ "M", - issueFromRightRel + this.#drawLeft, + issueFromRightRel, issueFromTop, "L", - issueFromRightRel + this.#drawLeft, + issueFromRightRel, issueMiddleTop ], { stroke: color, "stroke-width": RELATION_STROKE_WIDTH, fill: "none" } @@ -254,10 +246,10 @@ export default class extends Controller { this.#drawPath( [ "M", - issueFromRightRel + this.#drawLeft, + issueFromRightRel, issueMiddleTop, "L", - issueToLeftRel + this.#drawLeft, + issueToLeftRel, issueMiddleTop ], { stroke: color, "stroke-width": RELATION_STROKE_WIDTH, fill: "none" } @@ -265,10 +257,10 @@ export default class extends Controller { this.#drawPath( [ "M", - issueToLeftRel + this.#drawLeft, + issueToLeftRel, issueMiddleTop, "L", - issueToLeftRel + this.#drawLeft, + issueToLeftRel, issueToTop ], { stroke: color, "stroke-width": RELATION_STROKE_WIDTH, fill: "none" } @@ -276,10 +268,10 @@ export default class extends Controller { this.#drawPath( [ "M", - issueToLeftRel + this.#drawLeft, + issueToLeftRel, issueToTop, "L", - issueToLeft + this.#drawLeft, + issueToLeft, issueToTop ], { stroke: color, "stroke-width": RELATION_STROKE_WIDTH, fill: "none" } @@ -288,7 +280,7 @@ export default class extends Controller { this.#drawPath( [ "M", - issueToLeft + this.#drawLeft, + issueToLeft, issueToTop, "l", -4 * RELATION_STROKE_WIDTH, @@ -306,6 +298,11 @@ export default class extends Controller { }) } + #taskTop($task) { + const row = $task.closest(".gantt-row") + return row.position().top + $task.position().top + } + get #progressLinesArray() { const lines = [] const todayLeft = this.$("#today_line").position().left @@ -382,8 +379,8 @@ export default class extends Controller { (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 + const x1 = previous.left + const x2 = current.left this.#drawPath(["M", x1, previous.top, "L", x2, current.top], { stroke: color, @@ -393,4 +390,5 @@ export default class extends Controller { } } } + } diff --git a/app/javascript/controllers/gantt/column_controller.js b/app/javascript/controllers/gantt/column_controller.js index ba9bf108b..2c4bf7679 100644 --- a/app/javascript/controllers/gantt/column_controller.js +++ b/app/javascript/controllers/gantt/column_controller.js @@ -42,17 +42,13 @@ export default class extends Controller { } #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") + resize: (_event, ui) => { + this.element.style.setProperty("--gantt-column-width", `${ui.size.width}px`) + this.element.style.removeProperty("width") } } diff --git a/app/javascript/controllers/gantt/subjects_controller.js b/app/javascript/controllers/gantt/subjects_controller.js index 83062989c..219d7cf12 100644 --- a/app/javascript/controllers/gantt/subjects_controller.js +++ b/app/javascript/controllers/gantt/subjects_controller.js @@ -33,8 +33,8 @@ export default class extends Controller { 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}']` + 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 @@ -106,7 +106,7 @@ export default class extends Controller { #setBlockStart(el, value) { const node = el.jquery ? el[0] : el const px = typeof value === "number" ? `${value}px` : value - node.style.setProperty("inset-block-start", px) + node.style.setProperty("--gantt-row-top", px) } #setIconState(element, open) { diff --git a/app/views/gantts/_chart.html.erb b/app/views/gantts/_chart.html.erb index 52df9ea0a..e61fbf598 100644 --- a/app/views/gantts/_chart.html.erb +++ b/app/views/gantts/_chart.html.erb @@ -1,238 +1,107 @@ <% - 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 + layout = GanttHelper::ChartLayout.new(gantt) gantt.render( - top: headers_height + 8, - zoom: zoom, - g_width: g_width, - subject_width: subject_width + top: layout.content_top, + zoom: layout.zoom, + g_width: layout.chart_width, + subject_width: layout.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_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 %> <% 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 %> + + <% + 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, width: '50px', id: column_name, + class: 'gantt_selected_column', hidden: !query.draw_selected_columns) do %> +
+
<%= column.caption %>
+
+ <%= gantt.selected_column_content(column: column, top: layout.content_top, zoom: layout.zoom, g_width: layout.chart_width).html_safe %> +
+
<% 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") %> + <% end %> - <% 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 = +"inset-inline-start: #{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}" %> +
+
+
+
+ <% layout.months.each do |period| %> +
+ <%= link_to "#{period.date.year}-#{period.date.month}", + gantt.params.merge(year: period.date.year, month: period.date.month), + title: "#{month_name(period.date.month)} #{period.date.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 = +"inset-inline-start: #{left}px;" %> - <% gap_style << "inset-block-start: 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 = +"inset-inline-start: #{left}px;" %> - <% week_style << "inset-block-start: 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 %> + <% if layout.show_weeks? %> +
+ <% layout.weeks.each do |period| %> +
+ <%= period.date.cweek if period.date && period.width >= 16 %> +
<% 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 = +"inset-inline-start:#{left}px;" %> - <% day_style << "inset-block-start: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 %> + <% if layout.show_day_numbers? %> +
+ <% layout.day_numbers.each do |period| %> +
<%= period.date.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 = +"inset-inline-start: #{left}px;" %> - <% day_style << "inset-block-start: #{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) %> + <% if layout.show_days? %> +
+ <% layout.days.each do |period| %> +
<%= day_letter(period.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 << "inset-block-start: #{headers_height + 1}px;" %> - <% today_style << "inset-inline-start: #{today_left}px;" %> - <% today_style << "width:10px;" %> - <% today_style << "border-inline-start: 1px dashed red;" %> - <%= content_tag(:div, ' '.html_safe, style: today_style, id: 'today_line') %> + <% if layout.today_start %> +
" aria-hidden="true">
<% end %> - <% - draw_area_style = +"position: absolute;" - draw_area_style << "height: #{g_height}px;" - draw_area_style << "inset-block-start: #{headers_height + 1}px;" - draw_area_style << 'inset-inline-start: 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 %> diff --git a/lib/redmine/helpers/gantt.rb b/lib/redmine/helpers/gantt.rb index 4c10c644b..a8dc27212 100644 --- a/lib/redmine/helpers/gantt.rb +++ b/lib/redmine/helpers/gantt.rb @@ -283,8 +283,11 @@ module Redmine def render_object_row(object, options) class_name = object.class.name.downcase send(:"subject_for_#{class_name}", object, options) unless options[:only] == :lines || options[:only] == :selected_columns - send(:"line_for_#{class_name}", object, options) unless options[:only] == :subjects || options[:only] == :selected_columns - column_content_for_issue(object, options) if options[:only] == :selected_columns && options[:column].present? && object.is_a?(Issue) + 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 + 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 @@ -354,17 +357,23 @@ module Redmine end end - def column_content_for_issue(issue, options) + def column_content_for_object(object, options) if options[:format] == :html - data_options = {} - data_options[:collapse_expand] = "issue-#{issue.id}" - data_options[:number_of_rows] = number_of_rows - style = "position: absolute;inset-block-start: #{options[:top]}px; font-size: 0.8em;" + 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, view.column_content(options[:column], issue), - :style => style, :class => "issue_#{options[:column].name}", - :id => "#{options[:column].name}_issue_#{issue.id}", + :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 ) @columns[options[:column].name] << content if @columns.has_key?(options[:column].name) @@ -372,6 +381,10 @@ module Redmine end end + def column_content_for_issue(issue, options) + column_content_for_object(issue, options) + end + def subject(label, options, object=nil) send :"#{options[:format]}_subject", options, label, object end @@ -750,7 +763,7 @@ module Redmine s << view.assignee_avatar(issue.assigned_to, :size => 13, :class => 'icon-avatar') s << view.link_to_issue(issue).html_safe s << view.content_tag(:input, nil, :type => 'checkbox', :name => 'ids[]', - :value => issue.id, :style => 'display:none;', + :value => issue.id, :class => 'toggle-selection') view.content_tag(:span, s, :class => css_classes).html_safe when Version @@ -827,9 +840,15 @@ module Redmine params[:indent] += 18 end end - style = "position: absolute;inset-block-start:#{params[:top]}px;inset-inline-start:#{params[:indent]}px;" - style += "width:#{params[:subject_width] - params[:indent]}px;" if params[:subject_width] - tag_options[:style] = style + tag_options[:class] = [tag_options[:class], 'gantt-row', 'gantt-subject-row'].compact.join(' ') + 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 output @@ -889,10 +908,7 @@ module Redmine # Renders the task bar, with progress and late if coords[:bar_start] && coords[:bar_end] width = coords[:bar_end] - coords[:bar_start] - 2 - style = +"" - style << "inset-block-start:#{params[:top]}px;" - style << "inset-inline-start:#{coords[:bar_start]}px;" - style << "width:#{width}px;" + 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, @@ -909,10 +925,7 @@ module Redmine output << view.content_tag(:div, ' '.html_safe, content_opt) if coords[:bar_late_end] width = coords[:bar_late_end] - coords[:bar_start] - 2 - style = +"" - style << "inset-block-start:#{params[:top]}px;" - style << "inset-inline-start:#{coords[:bar_start]}px;" - style << "width:#{width}px;" + style = task_style(coords[:bar_start], width) output << view.content_tag(:div, ' '.html_safe, :style => style, :class => "#{css} task_late", @@ -920,10 +933,7 @@ module Redmine end if coords[:bar_progress_end] width = coords[:bar_progress_end] - coords[:bar_start] - 2 - style = +"" - style << "inset-block-start:#{params[:top]}px;" - style << "inset-inline-start:#{coords[:bar_start]}px;" - style << "width:#{width}px;" + 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, @@ -936,20 +946,14 @@ module Redmine # Renders the markers if markers if coords[:start] - style = +"" - style << "inset-block-start:#{params[:top]}px;" - style << "inset-inline-start:#{coords[:start]}px;" - style << "width:15px;" + style = task_style(coords[:start], 15) output << view.content_tag(:div, ' '.html_safe, :style => style, :class => "#{css} marker starting", :data => data_options) end if coords[:end] - style = +"" - style << "inset-block-start:#{params[:top]}px;" - style << "inset-inline-start:#{coords[:end]}px;" - style << "width:15px;" + style = task_style(coords[:end], 15) output << view.content_tag(:div, ' '.html_safe, :style => style, :class => "#{css} marker ending", @@ -958,10 +962,7 @@ module Redmine end # Renders the label on the right if label - style = +"" - style << "inset-block-start:#{params[:top]}px;" - style << "inset-inline-start:#{(coords[:bar_end] || 0) + 8}px;" - style << "width:15px;" + style = task_style((coords[:bar_end] || 0) + 8, 15) output << view.content_tag(:div, label, :style => style, :class => "#{css} label", @@ -973,23 +974,43 @@ module Redmine view.render_issue_tooltip(object).html_safe, :class => "tip") s += view.content_tag(:input, nil, :type => 'checkbox', :name => 'ids[]', - :value => object.id, :style => 'display:none;', + :value => object.id, :class => 'toggle-selection') - style = +"" - style << "position: absolute;" - style << "inset-block-start:#{params[:top]}px;" - style << "inset-inline-start:#{coords[:bar_start]}px;" - style << "width:#{coords[:bar_end] - coords[:bar_start]}px;" - style << "height:12px;" + 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) end - @lines << output output end + def html_gantt_row(object, params, content) + data_options = { + :collapse_expand => "#{object.class}-#{object.id}".downcase, + :number_of_rows => number_of_rows + } + 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', + :style => style, + :data => data_options + ) + end + + def task_style(start, width) + css_variables( + 'gantt-task-start': "#{start}px", + 'gantt-task-width': "#{width}px" + ) + end + + def css_variables(variables) + variables.map {|name, value| "--#{name}:#{value}"}.join(';') + end + def pdf_task(params, coords, markers, label, object) cell_height_ratio = params[:pdf].get_cell_height_ratio params[:pdf].set_cell_height_ratio(0.1) diff --git a/test/functional/gantts_controller_test.rb b/test/functional/gantts_controller_test.rb index 877a1a345..0cdf371f9 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[class=?]', 'issue-subject hascontextmenu' + assert_select 'div.issue-subject.hascontextmenu' assert_select 'div.tooltip.hascontextmenu' do assert_select 'img[class="gravatar avatar"]' end @@ -108,6 +108,15 @@ class GanttsControllerTest < Redmine::ControllerTest assert_response :success end + def test_gantt_should_warn_when_truncated + with_settings :gantt_items_limit => '1' do + get(:show, :params => {:project_id => 1}) + end + + assert_response :success + assert_select 'p.warning', :text => I18n.t(:notice_gantt_chart_truncated, :max => 1) + end + def test_show_should_run_custom_query query = IssueQuery.create!(:name => 'Gantt Query', :description => 'Description for Gantt Query', :visibility => IssueQuery::VISIBILITY_PUBLIC) get( @@ -214,7 +223,7 @@ class GanttsControllerTest < Redmine::ControllerTest } ) assert_response :success - assert_select 'div.gantt_hdr>a', :text => /^[\d-]+$/, :count => 40 + assert_select 'div.gantt-period-month>a', :text => /^[\d-]+$/, :count => 40 # Displays 6 months (the default value for `months`) if `months` exceeds # gant_months_limit @@ -227,7 +236,7 @@ class GanttsControllerTest < Redmine::ControllerTest } ) assert_response :success - assert_select 'div.gantt_hdr>a', :text => /^[\d-]+$/, :count => 6 + assert_select 'div.gantt-period-month>a', :text => /^[\d-]+$/, :count => 6 end end @@ -313,7 +322,7 @@ class GanttsControllerTest < Redmine::ControllerTest 6.times do |offset| m = selected_start.since(offset.month) - assert_select 'div.gantt_hdr > a', text: "#{m.year}-#{m.month}" + assert_select 'div.gantt-period-month > a', text: "#{m.year}-#{m.month}" end # eCookbook @@ -357,7 +366,7 @@ class GanttsControllerTest < Redmine::ControllerTest 6.times do |offset| m = start_of_month.since(offset.months) - assert_select 'div.gantt_hdr > a', text: "#{m.year}-#{m.month}" + assert_select 'div.gantt-period-month > a', text: "#{m.year}-#{m.month}" end assert_select 'input#months[value=?]', '6' @@ -382,6 +391,7 @@ class GanttsControllerTest < Redmine::ControllerTest 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 end diff --git a/test/helpers/gantt_helper_test.rb b/test/helpers/gantt_helper_test.rb new file mode 100644 index 000000000..9755c9c64 --- /dev/null +++ b/test/helpers/gantt_helper_test.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require_relative '../test_helper' + +class GanttHelperTest < Redmine::HelperTest + include GanttHelper + + test 'chart layout scales days and selects header rows for each zoom level' do + # Input zoom level, then expected pixels per day, header rows, and header visibility. + [ + [1, 2, 1, false, false, false], + [2, 4, 2, true, false, false], + [3, 8, 3, true, true, false], + [4, 16, 4, true, true, true] + ].each do |level, pixels, rows, weeks, days, day_numbers| + layout = chart_layout(zoom: level) + + assert_equal pixels, layout.zoom + assert_equal rows * layout.header_height, layout.headers_height + assert_equal weeks, layout.show_weeks? + assert_equal days, layout.show_days? + assert_equal day_numbers, layout.show_day_numbers? + end + end + + test 'chart width includes both endpoints of the date range' do + layout = chart_layout(date_from: Date.new(2024, 2, 1), zoom: 3) + + # Leap February has 29 days; zoom level 3 represents each day with 8 pixels. + assert_equal 232, layout.chart_width + end + + test 'content and pane heights account for the rendered rows' do + empty = chart_layout(number_of_rows: 0) + populated = chart_layout(number_of_rows: 10) + + assert_equal 270, empty.content_height + assert_equal 470, populated.content_height + assert_equal empty.headers_height + 270, empty.pane_height + assert_equal populated.headers_height + 470, populated.pane_height + end + + test 'monthly periods cross a year boundary and include leap February' do + layout = chart_layout( + date_from: Date.new(2023, 12, 1), date_to: Date.new(2024, 2, 29), months: 3, zoom: 3 + ) + periods = layout.months + + assert_equal [Date.new(2023, 12, 1), Date.new(2024, 1, 1), Date.new(2024, 2, 1)], periods.map(&:date) + # December, January, and leap February have 31, 31, and 29 days, at 8 pixels per day. + assert_equal [248, 248, 232], periods.map(&:width) + assert_equal layout.chart_width, periods.sum(&:width) + assert_equal [false, false, true], periods.map(&:last) + assert_equal [false, false, false], periods.map(&:spans_body) + end + + test 'monthly periods extend through the body at zoom level one' do + assert_equal [true], chart_layout(zoom: 1).months.map(&:spans_body) + end + + test 'weeks beginning on Monday have no leading gap and clip the final week' do + layout = chart_layout(date_from: Date.new(2026, 6, 1), zoom: 2) + periods = layout.weeks + + assert_equal [1, 8, 15, 22, 29].map {|day| Date.new(2026, 6, day)}, periods.map(&:date) + assert_equal [28, 28, 28, 28, 8], periods.map(&:width) + assert_equal layout.chart_width, periods.sum(&:width) + assert_equal [false, false, false, false, true], periods.map(&:last) + assert_equal [true, true, true, true, true], periods.map(&:spans_body) + end + + test 'weeks beginning after Monday include a leading partial week' do + layout = chart_layout(date_from: Date.new(2026, 9, 1), zoom: 3) + periods = layout.weeks + + assert_equal [nil, Date.new(2026, 9, 7), Date.new(2026, 9, 14), Date.new(2026, 9, 21), Date.new(2026, 9, 28)], periods.map(&:date) + assert_equal [48, 56, 56, 56, 24], periods.map(&:width) + assert_equal layout.chart_width, periods.sum(&:width) + assert_equal [false, false, false, false, true], periods.map(&:last) + assert_equal [false, false, false, false, false], periods.map(&:spans_body) + end + + test 'weeks ending on Sunday retain a full final week' do + layout = chart_layout(date_from: Date.new(2021, 2, 1), zoom: 2) + periods = layout.weeks + + assert_equal [1, 8, 15, 22].map {|day| Date.new(2021, 2, day)}, periods.map(&:date) + assert_equal [28, 28, 28, 28], periods.map(&:width) + assert_equal layout.chart_width, periods.sum(&:width) + assert_equal [false, false, false, true], periods.map(&:last) + end + + test 'weeks are empty at zoom level one' do + assert_empty chart_layout(zoom: 1).weeks + end + + test 'day numbers include every date and its working day status' do + layout = chart_layout(date_from: Date.new(2024, 2, 1), zoom: 4) + periods = layout.day_numbers + + assert_equal (1..29).map {|day| Date.new(2024, 2, day)}, periods.map(&:date) + assert_equal [16] * 29, periods.map(&:width) + assert_equal [3, 4, 10, 11, 17, 18, 24, 25], periods.select(&:non_working).map {|period| period.date.day} + assert_equal [false] * 28 + [true], periods.map(&:last) + assert_equal [false] * 29, periods.map(&:spans_body) + assert_equal layout.chart_width, periods.sum(&:width) + end + + test 'day numbers are empty at zoom level three' do + assert_empty chart_layout(zoom: 3).day_numbers + end + + test 'days extend through the body and respect configured non-working weekdays' do + layout = chart_layout(date_from: Date.new(2026, 9, 1), non_working_week_days: [2]) + periods = layout.days + + assert_equal (1..30).map {|day| Date.new(2026, 9, day)}, periods.map(&:date) + assert_equal [8] * 30, periods.map(&:width) + assert_equal [1, 8, 15, 22, 29], periods.select(&:non_working).map {|period| period.date.day} + assert_equal [false] * 29 + [true], periods.map(&:last) + assert_equal [true] * 30, periods.map(&:spans_body) + assert_equal layout.chart_width, periods.sum(&:width) + end + + test 'days are empty at zoom level two' do + assert_empty chart_layout(zoom: 2).days + end + + test 'today line marks the right edge of the current day including range endpoints' do + layout = chart_layout(date_from: Date.new(2024, 2, 1)) + + [ + [Date.new(2024, 2, 1), 7], + [Date.new(2024, 2, 15), 119], + [Date.new(2024, 2, 29), 231] + ].each do |today, position| + User.current.stubs(:today).returns(today) + assert_equal position, layout.today_start + end + end + + test 'today line is absent outside the displayed range' do + layout = chart_layout(date_from: Date.new(2024, 2, 1)) + + [Date.new(2024, 1, 31), Date.new(2024, 3, 1)].each do |today| + User.current.stubs(:today).returns(today) + assert_nil layout.today_start + end + end + + private + + def chart_layout(date_from: Date.new(2026, 6, 1), date_to: date_from.end_of_month, + months: 1, zoom: 3, number_of_rows: 0, non_working_week_days: [6, 7]) + gantt = stub( + date_from: date_from, date_to: date_to, months: months, zoom: zoom, + number_of_rows: number_of_rows, non_working_week_days: non_working_week_days + ) + GanttHelper::ChartLayout.new(gantt) + end +end diff --git a/test/system/gantts_test.rb b/test/system/gantts_test.rb index 7edcd31dc..2eeb62790 100644 --- a/test/system/gantts_test.rb +++ b/test/system/gantts_test.rb @@ -11,45 +11,53 @@ class GanttsTest < ApplicationSystemTestCase visit_gantt expand_options - assert_no_selector 'td#status' - assert_no_selector 'td#priority' - assert_no_selector 'td#assigned_to' - assert_no_selector 'td#updated_on' + assert_no_selector 'div#status' + assert_no_selector 'div#priority' + assert_no_selector 'div#assigned_to' + assert_no_selector 'div#updated_on' find('#draw_selected_columns').check - 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' + 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' 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-relations path', minimum: 1 find('#draw_relations').uncheck - assert_no_selector '#gantt_draw_area path' + assert_no_selector '.gantt-relations path' find('#draw_relations').check - assert_selector '#gantt_draw_area path', minimum: 1 + 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 + find('.gantt-timeline').scroll_to(200, 0) + find('#draw_relations').check + + assert_equal paths_before_scroll, all('.gantt-relations path').pluck(:d) end - test 'progress line toggle draws zigzag line' do + test 'progress line option displays progress line' do visit_gantt expand_options find('#draw_relations').uncheck - assert_no_selector '#gantt_draw_area path' + assert_no_selector '.gantt-relations path' find('#draw_progress_line').check - assert_selector '#gantt_draw_area path', minimum: 1 + assert_selector '.gantt-relations path', minimum: 1 end test 'selected columns can be resized by dragging' do @@ -107,11 +115,11 @@ class GanttsTest < ApplicationSystemTestCase end def column_width(id) - page.evaluate_script("document.querySelector('td##{id}').offsetWidth") + page.evaluate_script("document.querySelector('div##{id}').offsetWidth") end def drag_column_resizer(column_id, distance) - handle = find("td##{column_id} .ui-resizable-e") + handle = find("div##{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 9a469c429..7ebc24e9e 100644 --- a/test/unit/lib/redmine/helpers/gantt_test.rb +++ b/test/unit/lib/redmine/helpers/gantt_test.rb @@ -135,14 +135,15 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest setup_subjects @output_buffer = @gantt.subjects assert_select "div.project-name a", /#{@project.name}/ - assert_select 'div.project-name[style*="inset-inline-start:4px"]' + assert_select "div.project-name.gantt-row.gantt-subject-row" + assert_select 'div.project-name[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*="inset-inline-start:24px"]' + assert_select 'div.version-name[style*="--gantt-row-indent:24px"]' end test "#subjects version without assigned issues should not be rendered" do @@ -160,7 +161,7 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest @output_buffer = @gantt.subjects assert_select "div.issue-subject", /#{@issue.subject}/ # subject 62px: 44px + 18px(collapse/expand icon's width) - assert_select 'div.issue-subject[style*="inset-inline-start:62px"]' + assert_select 'div.issue-subject[style*="--gantt-row-indent:62px"]' end test "#subjects issue assigned to a shared version of another project should be rendered" do @@ -205,13 +206,13 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest ) @output_buffer = @gantt.subjects # parent task 44px - assert_select 'div.issue-subject[style*="inset-inline-start:44px"]', /#{@issue.subject}/ + assert_select 'div.issue-subject[style*="--gantt-row-indent:44px"]', /#{@issue.subject}/ # children 64px - assert_select 'div.issue-subject[style*="inset-inline-start:64px"]', /child1/ + assert_select 'div.issue-subject[style*="--gantt-row-indent:64px"]', /child1/ # children 76px: 64px + 18px(collapse/expand icon's width) - assert_select 'div.issue-subject[style*="inset-inline-start:82px"]', /child2/ + assert_select 'div.issue-subject[style*="--gantt-row-indent:82px"]', /child2/ # grandchild 96px: 84px + 18px(collapse/expand icon's width) - assert_select 'div.issue-subject[style*="inset-inline-start:102px"]', /grandchild/, @output_buffer + assert_select 'div.issue-subject[style*="--gantt-row-indent:102px"]', /grandchild/, @output_buffer end test "#lines" do @@ -231,6 +232,7 @@ 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" @@ -254,9 +256,23 @@ 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}" end + test "#lines keeps an empty row for an issue without dates" do + create_gantt + issue = Issue.generate!(:project => @project, :start_date => nil, :due_date => nil) + @project.issues << issue + + @output_buffer = @gantt.lines + + assert_select "div.gantt-row.gantt-timeline-row[data-collapse-expand='issue-#{issue.id}']" do + assert_select ".task", 0 + end + end + test "#subject_for_project" do create_gantt @output_buffer = @gantt.subject_for_project(@project, :format => :html) @@ -309,17 +325,17 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest assert_select 'div span.issue-overdue' end - test "#subject should add an absolute positioned div" do + test "#subject should add a positioned div" do create_gantt @output_buffer = @gantt.subject('subject', :format => :html) - assert_select "div[style*=absolute]", :text => 'subject' + assert_select 'div[style*="--gantt-row-top:"]', :text => 'subject' end test "#subject should use the indent option to move the div to the right" do create_gantt @output_buffer = @gantt.subject('subject', :format => :html, :indent => 40) # subject 52px: 40px(indent) + 12px(collapse/expand icon's width) - assert_select 'div[style*="inset-inline-start:58px"]' + assert_select 'div[style*="--gantt-row-indent:58px"]' end test "#line_for_project" do @@ -353,7 +369,7 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest 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*="inset-inline-start:28px"]', 1 + assert_select 'div.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 @@ -361,8 +377,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*="inset-inline-start:0px"]', 1, @output_buffer - assert_select 'div.task_todo[style*="width:2px"]', 1, @output_buffer + 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 end end @@ -371,84 +387,84 @@ 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*="inset-inline-start:112px"]', 1, @output_buffer - assert_select 'div.task_todo[style*="width:2px"]', 1, @output_buffer + 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 end end test "#line todo line should be the total width" do create_gantt @output_buffer = @gantt.line(today - 7, today + 7, 30, false, 'line', :format => :html, :zoom => 4) - assert_select 'div.task_todo[style*="width:58px"]', 1 + assert_select 'div.task_todo[style*="--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*="inset-inline-start:28px"]', 1 + assert_select 'div.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*="width:30px"]', 1 + assert_select 'div.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*="width:2px"]', 1 - assert_select 'div.task_todo[style*="width:2px"]', 1 + assert_select 'div.task_late[style*="--gantt-task-width:2px"]', 1 + assert_select 'div.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*="width:2px"]', 1 - assert_select 'div.task_todo[style*="width:2px"]', 1 + assert_select 'div.task_late[style*="--gantt-task-width:2px"]', 1 + assert_select 'div.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*="inset-inline-start:28px"]', 1 + assert_select 'div.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*="width:16px"]', 1 + assert_select 'div.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*="width:58px"]', 1 + assert_select 'div.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*="width:2px"]', 1 + assert_select 'div.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*="inset-inline-start:0px"]', 1 - assert_select 'div.task_done[style*="width:8px"]', 1 + assert_select 'div.task_done[style*="--gantt-task-start:0px"]', 1 + assert_select 'div.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*="inset-inline-start:28px"]', 1 + assert_select 'div.starting[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*="inset-inline-start:0px"]', 1 + assert_select 'div.starting[style*="--gantt-task-start:0px"]', 1 end test "#line starting marker should not appear if the start date is before gantt start date" do @@ -461,10 +477,10 @@ class Redmine::Helpers::GanttHelperTest < Redmine::HelperTest 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*="inset-inline-start:88px"]', 1 + assert_select 'div.ending[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*="inset-inline-start:116px"]', 1 + assert_select 'div.ending[style*="--gantt-task-start:116px"]', 1 end test "#line ending marker should not appear if the end date is before gantt start date" do -- 2.55.0