From 632c583c9fd5baa77ce9aa1d5ab8ebc251dcc02d Mon Sep 17 00:00:00 2001 From: Katsuya HIDAKA Date: Fri, 2 Oct 2026 17:34:24 +0900 Subject: Remember Gantt column widths Save and restore column widths globally across all Gantt charts. Shrink the subject column when space is limited, and allow horizontal scrolling when the columns still do not fit. Preserve the displayed subject width during manual resizing. --- app/assets/stylesheets/gantt.css | 30 +++++++- app/helpers/gantt_helper.rb | 22 +++++- .../controllers/gantt/chart_controller.js | 45 ++++++++++- .../controllers/gantt/column_controller.js | 77 +++++++++++++++++++ app/views/gantts/_chart.html.erb | 2 +- test/system/gantts_test.rb | 48 ++++++++++++ 6 files changed, 217 insertions(+), 7 deletions(-) diff --git a/app/assets/stylesheets/gantt.css b/app/assets/stylesheets/gantt.css index 226f7de38..d4447857a 100644 --- a/app/assets/stylesheets/gantt.css +++ b/app/assets/stylesheets/gantt.css @@ -24,11 +24,17 @@ inline-size: 100%; /* Reserve space for the horizontal scrollbar so navigation stays in place. */ min-block-size: calc(var(--gantt-pane-height) + 24px); - overflow: hidden; + overflow-x: auto; + overflow-y: 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; } +/* Avoid flicker by hiding the chart until all column widths are restored. */ +.gantt-chart:has(.gantt-column:not([data-gantt--column-width-restored])) { + visibility: hidden; +} + .gantt-chart [hidden] { display: none !important; } @@ -40,6 +46,18 @@ min-inline-size: 0; } +.gantt-column[data-gantt-column="subjects"] { + min-inline-size: 100px; + max-inline-size: max(100px, calc(100% - 100px)); +} + +/* On a narrow chart, dragging may make the subject column wider than the available space. */ +.gantt-chart.is-manually-resized [data-gantt-column="subjects"] { + flex: 0 0 var(--gantt-subject-display-width); + inline-size: var(--gantt-subject-display-width); + max-inline-size: none; +} + .gantt-chart .ui-resizable-e { cursor: ew-resize; } @@ -84,8 +102,8 @@ .gantt-timeline { position: relative; - flex: 1 1 auto; - min-inline-size: 0; + flex: 1 0 0; + min-inline-size: 100px; /* Keep the canvas border inside the overflow clipping edge. */ padding-block-end: 1px; overflow-x: auto; @@ -407,4 +425,10 @@ inline-size: calc(var(--gantt-chart-width) + 1px); overflow: visible; } + + .gantt-chart .gantt-column[data-gantt-column="subjects"] { + flex: 0 0 var(--gantt-column-width); + inline-size: var(--gantt-column-width); + max-inline-size: none; + } } diff --git a/app/helpers/gantt_helper.rb b/app/helpers/gantt_helper.rb index 31fb6b1bb..d9116fcc7 100644 --- a/app/helpers/gantt_helper.rb +++ b/app/helpers/gantt_helper.rb @@ -232,6 +232,7 @@ module GanttHelper mouseout->gantt--chart#unhighlightRow resize@window->gantt--chart#handleWindowResize ).join(' '), + 'gantt--chart-manually-resized-class': 'is-manually-resized', 'gantt--chart-issue-relation-types-value': Redmine::Helpers::Gantt::DRAW_TYPES.to_json, 'gantt--chart-show-selected-columns-value': query.draw_selected_columns ? 'true' : 'false', 'gantt--chart-show-relations-value': query.draw_relations ? 'true' : 'false', @@ -253,10 +254,27 @@ module GanttHelper end end + def gantt_subject_column_tag(&) + gantt_column_tag( + 'subjects', min_width: 100, width: 'var(--gantt-subject-width)', + data: { + 'gantt--chart-target': 'subjectColumn', + # When the subject column is dragged on a narrow chart, + # let the user's chosen width take precedence over automatic shrinking. + action: %w( + gantt--column:resize-start->gantt--chart#handleSubjectColumnResizeStart + gantt--column:resize->gantt--chart#handleSubjectColumnResize + ).join(' ') + }, & + ) + end + def gantt_column_tag(column_name, min_width: nil, **options, &) - options[:data] = options.fetch(:data, {}).merge( + data_attributes = options.fetch(:data, {}) + + options[:data] = data_attributes.merge( controller: 'gantt--column', - action: 'resize@window->gantt--column#handleWindowResize', + action: ['resize@window->gantt--column#handleWindowResize', data_attributes[:action]].compact.join(' '), 'gantt--column-min-width-value': min_width, 'gantt-column': column_name ) diff --git a/app/javascript/controllers/gantt/chart_controller.js b/app/javascript/controllers/gantt/chart_controller.js index 5ce8fb4d8..96f3e851a 100644 --- a/app/javascript/controllers/gantt/chart_controller.js +++ b/app/javascript/controllers/gantt/chart_controller.js @@ -4,7 +4,8 @@ const RELATION_STROKE_WIDTH = 2 const SVG_NS = "http://www.w3.org/2000/svg" export default class extends Controller { - static targets = ["relations", "selectedColumn", "today"] + static targets = ["relations", "selectedColumn", "subjectColumn", "today"] + static classes = ["manuallyResized"] static values = { issueRelationTypes: Object, @@ -17,6 +18,8 @@ export default class extends Controller { #drawRight = 0 #drawPaper = null #drawPaperGroup = null + #chartWidth = 0 + #resizeObserver = null initialize() { this.$ = window.jQuery @@ -25,13 +28,21 @@ export default class extends Controller { connect() { this.#drawTop = 0 this.#drawRight = 0 + this.#chartWidth = this.element.clientWidth this.#drawProgressLineAndRelations() this.#drawSelectedColumns() + + // Opening or closing the sidebar changes the chart width without resizing the window. + this.#resizeObserver = new ResizeObserver(() => this.handleChartResize()) + this.#resizeObserver.observe(this.element) } disconnect() { + this.#resizeObserver?.disconnect() + this.#resizeObserver = null this.clearRowHighlight() + this.#resetSubjectDisplayWidth() if (this.#drawPaper) { this.#drawPaper.remove() @@ -75,10 +86,35 @@ export default class extends Controller { } handleWindowResize() { + this.handleChartResize() this.#drawProgressLineAndRelations() this.#drawSelectedColumns() } + // After manual column sizing, narrowing the window or opening the sidebar + // should let the subject column shrink to fit again. + handleChartResize() { + const width = this.element.clientWidth + if (width === this.#chartWidth) return + + this.#resetSubjectDisplayWidth() + this.#chartWidth = width + } + + // When a subject column widened on a larger screen is dragged on a smaller screen, + // start from its displayed width so the handle stays under the pointer. + handleSubjectColumnResizeStart() { + if (this.element.classList.contains(this.manuallyResizedClass)) return + + const width = this.subjectColumnTarget.getBoundingClientRect().width + this.element.style.setProperty("--gantt-subject-display-width", `${width}px`) + this.element.classList.add(this.manuallyResizedClass) + } + + handleSubjectColumnResize(event) { + this.element.style.setProperty("--gantt-subject-display-width", `${event.detail.width}px`) + } + handleSubjectTreeChanged() { this.#drawProgressLineAndRelations() this.#drawSelectedColumns() @@ -96,6 +132,13 @@ export default class extends Controller { this.showProgressValue = !!(event.detail && event.detail.enabled) } + // After the chart is resized or reconnected, let the subject column fit again, + // keeping the user's chosen width for when more space is available. + #resetSubjectDisplayWidth() { + this.element.classList.remove(this.manuallyResizedClass) + this.element.style.removeProperty("--gantt-subject-display-width") + } + #setRowHighlight(row, highlighted) { const rowKey = row.dataset.ganttRowKey if (!rowKey) return diff --git a/app/javascript/controllers/gantt/column_controller.js b/app/javascript/controllers/gantt/column_controller.js index 2bc4a1d15..4cd53c3ee 100644 --- a/app/javascript/controllers/gantt/column_controller.js +++ b/app/javascript/controllers/gantt/column_controller.js @@ -1,5 +1,48 @@ import { Controller } from "@hotwired/stimulus" +const COLUMN_WIDTH_STORE_KEY = "redmine-gantt-column-width" + +class ColumnWidthStore { + constructor(key) { + this.key = key + } + + get(column) { + const width = this.#read()[column] + return Number.isFinite(width) ? width : null + } + + set(column, width) { + if (!Number.isFinite(width)) return + + const widths = this.#read() + widths[column] = width + this.#write(widths) + } + + #read() { + try { + const value = window.localStorage.getItem(this.key) + if (value === null) return {} + + const widths = JSON.parse(value) + if (!widths || typeof widths !== "object" || Array.isArray(widths)) { + return {} + } + + return widths + } catch (_error) { + return {} + } + } + + #write(widths) { + try { + window.localStorage.setItem(this.key, JSON.stringify(widths)) + } catch (_error) {} + } +} + export default class extends Controller { static values = { minWidth: Number, @@ -8,6 +51,7 @@ export default class extends Controller { } #$element = null + #columnWidthStore = null initialize() { this.$ = window.jQuery @@ -15,12 +59,16 @@ export default class extends Controller { connect() { this.#$element = this.$(this.element) + this.#columnWidthStore = new ColumnWidthStore(COLUMN_WIDTH_STORE_KEY) + + this.#restoreWidth() this.#setupResizable() } disconnect() { this.#$element?.resizable("destroy") this.#$element = null + this.#columnWidthStore = null } handleWindowResize(_event) { @@ -37,20 +85,49 @@ export default class extends Controller { } } + get #columnName() { + return this.element.dataset.ganttColumn + } + #setupResizable() { const options = { handles: "e", minWidth: this.minWidthValue, zIndex: 30, + start: () => { + this.dispatch("resize-start") + }, resize: (_event, ui) => { this.element.style.setProperty("--gantt-column-width", `${ui.size.width}px`) this.element.style.removeProperty("width") + this.dispatch("resize", { detail: { width: ui.size.width } }) + }, + stop: (_event, ui) => { + if (ui.originalSize.width !== ui.size.width) { + this.#saveWidth(ui.size.width) + } } } this.#$element.resizable(options) } + #restoreWidth() { + const width = this.#columnWidthStore.get(this.#columnName) + + if (width !== null && width >= this.minWidthValue) { + this.element.style.setProperty("--gantt-column-width", `${width}px`) + } + + this.data.set("width-restored", "") + } + + #saveWidth(width) { + if (width < this.minWidthValue) return + + this.#columnWidthStore.set(this.#columnName, width) + } + #isMobile() { return !!(typeof window.isMobile === "function" && window.isMobile()) } diff --git a/app/views/gantts/_chart.html.erb b/app/views/gantts/_chart.html.erb index 3677cd6a0..68fb42c35 100644 --- a/app/views/gantts/_chart.html.erb +++ b/app/views/gantts/_chart.html.erb @@ -13,7 +13,7 @@ <% end %> <%= gantt_chart_tag(@query, layout) do %> - <%= gantt_column_tag('subjects', min_width: 100, width: 'var(--gantt-subject-width)') do %> + <%= gantt_subject_column_tag do %>
diff --git a/test/system/gantts_test.rb b/test/system/gantts_test.rb index 2bdc5e37c..a190cd805 100644 --- a/test/system/gantts_test.rb +++ b/test/system/gantts_test.rb @@ -107,6 +107,54 @@ class GanttsTest < ApplicationSystemTestCase assert width_after > width_before end + test 'column widths are remembered globally' do + visit_gantt + + # Wait for column resizing to initialize before reading the width. + assert_selector '.gantt-column[data-gantt-column="subjects"].ui-resizable' + default_subject_width = column_width('subjects') + + drag_column_resizer('subjects', 60) + resized_subject_width = column_width('subjects') + assert resized_subject_width > default_subject_width + + page.refresh + assert_selector '.gantt-column[data-gantt-column="subjects"].ui-resizable' + assert_equal resized_subject_width, column_width('subjects') + + expand_options + find('#draw_selected_columns').check + drag_column_resizer('status', 80) + resized_status_width = column_width('status') + + ['/projects/subproject1/issues/gantt', '/issues/gantt'].each do |path| + visit path + assert_selector '.gantt-column[data-gantt-column="subjects"].ui-resizable' + assert_equal resized_subject_width, column_width('subjects') + + expand_options + find('#draw_selected_columns').check + assert_equal resized_status_width, column_width('status') + end + end + + test 'subject width adapts when the sidebar is opened and closed after manual resizing' do + visit_gantt + subject_column = '.gantt-column[data-gantt-column="subjects"]' + assert_selector "#{subject_column}.ui-resizable" + + find('#sidebar-switch-button').click + drag_column_resizer('subjects', 500) + wide_width = column_width('subjects') + + find('#sidebar-switch-button').click + assert_selector(subject_column) { column_width('subjects') < wide_width } + narrow_width = column_width('subjects') + + find('#sidebar-switch-button').click + assert_selector(subject_column) { column_width('subjects') > narrow_width } + end + test 'context menu and tooltip interactions' do visit_gantt -- 2.51.0