Project

General

Profile

Feature #44608 » 0001-Remember-Gantt-column-widths.patch

Katsuya HIDAKA, 2026-10-14 11:13

View differences:

app/assets/stylesheets/gantt.css
24 24
  inline-size: 100%;
25 25
  /* Reserve space for the horizontal scrollbar so navigation stays in place. */
26 26
  min-block-size: calc(var(--gantt-pane-height) + 24px);
27
  overflow: hidden;
27
  overflow-x: auto;
28
  overflow-y: hidden;
28 29
  /* Force LTR for Gantt even in RTL locales because column resizing, relation lines, and the progress line don't work in RTL yet. */
29 30
  direction: ltr;
30 31
}
31 32

  
33
/* Avoid flicker by hiding the chart until all column widths are restored. */
34
.gantt-chart:has(.gantt-column:not([data-gantt--column-width-restored])) {
35
  visibility: hidden;
36
}
37

  
32 38
.gantt-chart [hidden] {
33 39
  display: none !important;
34 40
}
......
40 46
  min-inline-size: 0;
41 47
}
42 48

  
49
.gantt-column[data-gantt-column="subjects"] {
50
  min-inline-size: 100px;
51
  max-inline-size: max(100px, calc(100% - 100px));
52
}
53

  
54
/* On a narrow chart, dragging may make the subject column wider than the available space. */
55
.gantt-chart.is-manually-resized [data-gantt-column="subjects"] {
56
  flex: 0 0 var(--gantt-subject-display-width);
57
  inline-size: var(--gantt-subject-display-width);
58
  max-inline-size: none;
59
}
60

  
43 61
.gantt-chart .ui-resizable-e {
44 62
  cursor: ew-resize;
45 63
}
......
84 102

  
85 103
.gantt-timeline {
86 104
  position: relative;
87
  flex: 1 1 auto;
88
  min-inline-size: 0;
105
  flex: 1 0 0;
106
  min-inline-size: 100px;
89 107
  /* Keep the canvas border inside the overflow clipping edge. */
90 108
  padding-block-end: 1px;
91 109
  overflow-x: auto;
......
407 425
    inline-size: calc(var(--gantt-chart-width) + 1px);
408 426
    overflow: visible;
409 427
  }
428

  
429
  .gantt-chart .gantt-column[data-gantt-column="subjects"] {
430
    flex: 0 0 var(--gantt-column-width);
431
    inline-size: var(--gantt-column-width);
432
    max-inline-size: none;
433
  }
410 434
}
app/helpers/gantt_helper.rb
232 232
        mouseout->gantt--chart#unhighlightRow
233 233
        resize@window->gantt--chart#handleWindowResize
234 234
      ).join(' '),
235
      'gantt--chart-manually-resized-class': 'is-manually-resized',
235 236
      'gantt--chart-issue-relation-types-value': Redmine::Helpers::Gantt::DRAW_TYPES.to_json,
236 237
      'gantt--chart-show-selected-columns-value': query.draw_selected_columns ? 'true' : 'false',
237 238
      'gantt--chart-show-relations-value': query.draw_relations ? 'true' : 'false',
......
253 254
    end
254 255
  end
255 256

  
257
  def gantt_subject_column_tag(&)
258
    gantt_column_tag(
259
      'subjects', min_width: 100, width: 'var(--gantt-subject-width)',
260
      data: {
261
        'gantt--chart-target': 'subjectColumn',
262
        # When the subject column is dragged on a narrow chart,
263
        # let the user's chosen width take precedence over automatic shrinking.
264
        action: %w(
265
          gantt--column:resize-start->gantt--chart#handleSubjectColumnResizeStart
266
          gantt--column:resize->gantt--chart#handleSubjectColumnResize
267
        ).join(' ')
268
      }, &
269
    )
270
  end
271

  
256 272
  def gantt_column_tag(column_name, min_width: nil, **options, &)
257
    options[:data] = options.fetch(:data, {}).merge(
273
    data_attributes = options.fetch(:data, {})
274

  
275
    options[:data] = data_attributes.merge(
258 276
      controller: 'gantt--column',
259
      action: 'resize@window->gantt--column#handleWindowResize',
277
      action: ['resize@window->gantt--column#handleWindowResize', data_attributes[:action]].compact.join(' '),
260 278
      'gantt--column-min-width-value': min_width,
261 279
      'gantt-column': column_name
262 280
    )
app/javascript/controllers/gantt/chart_controller.js
4 4
const SVG_NS = "http://www.w3.org/2000/svg"
5 5

  
6 6
export default class extends Controller {
7
  static targets = ["relations", "selectedColumn", "today"]
7
  static targets = ["relations", "selectedColumn", "subjectColumn", "today"]
8
  static classes = ["manuallyResized"]
8 9

  
9 10
  static values = {
10 11
    issueRelationTypes: Object,
......
17 18
  #drawRight = 0
18 19
  #drawPaper = null
19 20
  #drawPaperGroup = null
21
  #chartWidth = 0
22
  #resizeObserver = null
20 23

  
21 24
  initialize() {
22 25
    this.$ = window.jQuery
......
25 28
  connect() {
26 29
    this.#drawTop = 0
27 30
    this.#drawRight = 0
31
    this.#chartWidth = this.element.clientWidth
28 32

  
29 33
    this.#drawProgressLineAndRelations()
30 34
    this.#drawSelectedColumns()
35

  
36
    // Opening or closing the sidebar changes the chart width without resizing the window.
37
    this.#resizeObserver = new ResizeObserver(() => this.handleChartResize())
38
    this.#resizeObserver.observe(this.element)
31 39
  }
32 40

  
33 41
  disconnect() {
42
    this.#resizeObserver?.disconnect()
43
    this.#resizeObserver = null
34 44
    this.clearRowHighlight()
45
    this.#resetSubjectDisplayWidth()
35 46

  
36 47
    if (this.#drawPaper) {
37 48
      this.#drawPaper.remove()
......
75 86
  }
76 87

  
77 88
  handleWindowResize() {
89
    this.handleChartResize()
78 90
    this.#drawProgressLineAndRelations()
79 91
    this.#drawSelectedColumns()
80 92
  }
81 93

  
94
  // After manual column sizing, narrowing the window or opening the sidebar
95
  // should let the subject column shrink to fit again.
96
  handleChartResize() {
97
    const width = this.element.clientWidth
98
    if (width === this.#chartWidth) return
99

  
100
    this.#resetSubjectDisplayWidth()
101
    this.#chartWidth = width
102
  }
103

  
104
  // When a subject column widened on a larger screen is dragged on a smaller screen,
105
  // start from its displayed width so the handle stays under the pointer.
106
  handleSubjectColumnResizeStart() {
107
    if (this.element.classList.contains(this.manuallyResizedClass)) return
108

  
109
    const width = this.subjectColumnTarget.getBoundingClientRect().width
110
    this.element.style.setProperty("--gantt-subject-display-width", `${width}px`)
111
    this.element.classList.add(this.manuallyResizedClass)
112
  }
113

  
114
  handleSubjectColumnResize(event) {
115
    this.element.style.setProperty("--gantt-subject-display-width", `${event.detail.width}px`)
116
  }
117

  
82 118
  handleSubjectTreeChanged() {
83 119
    this.#drawProgressLineAndRelations()
84 120
    this.#drawSelectedColumns()
......
96 132
    this.showProgressValue = !!(event.detail && event.detail.enabled)
97 133
  }
98 134

  
135
  // After the chart is resized or reconnected, let the subject column fit again,
136
  // keeping the user's chosen width for when more space is available.
137
  #resetSubjectDisplayWidth() {
138
    this.element.classList.remove(this.manuallyResizedClass)
139
    this.element.style.removeProperty("--gantt-subject-display-width")
140
  }
141

  
99 142
  #setRowHighlight(row, highlighted) {
100 143
    const rowKey = row.dataset.ganttRowKey
101 144
    if (!rowKey) return
app/javascript/controllers/gantt/column_controller.js
1 1
import { Controller } from "@hotwired/stimulus"
2 2

  
3
const COLUMN_WIDTH_STORE_KEY = "redmine-gantt-column-width"
4

  
5
class ColumnWidthStore {
6
  constructor(key) {
7
    this.key = key
8
  }
9

  
10
  get(column) {
11
    const width = this.#read()[column]
12
    return Number.isFinite(width) ? width : null
13
  }
14

  
15
  set(column, width) {
16
    if (!Number.isFinite(width)) return
17

  
18
    const widths = this.#read()
19
    widths[column] = width
20
    this.#write(widths)
21
  }
22

  
23
  #read() {
24
    try {
25
      const value = window.localStorage.getItem(this.key)
26
      if (value === null) return {}
27

  
28
      const widths = JSON.parse(value)
29
      if (!widths || typeof widths !== "object" || Array.isArray(widths)) {
30
        return {}
31
      }
32

  
33
      return widths
34
    } catch (_error) {
35
      return {}
36
    }
37
  }
38

  
39
  #write(widths) {
40
    try {
41
      window.localStorage.setItem(this.key, JSON.stringify(widths))
42
    } catch (_error) {}
43
  }
44
}
45

  
3 46
export default class extends Controller {
4 47
  static values = {
5 48
    minWidth: Number,
......
8 51
  }
9 52

  
10 53
  #$element = null
54
  #columnWidthStore = null
11 55

  
12 56
  initialize() {
13 57
    this.$ = window.jQuery
......
15 59

  
16 60
  connect() {
17 61
    this.#$element = this.$(this.element)
62
    this.#columnWidthStore = new ColumnWidthStore(COLUMN_WIDTH_STORE_KEY)
63

  
64
    this.#restoreWidth()
18 65
    this.#setupResizable()
19 66
  }
20 67

  
21 68
  disconnect() {
22 69
    this.#$element?.resizable("destroy")
23 70
    this.#$element = null
71
    this.#columnWidthStore = null
24 72
  }
25 73

  
26 74
  handleWindowResize(_event) {
......
37 85
    }
38 86
  }
39 87

  
88
  get #columnName() {
89
    return this.element.dataset.ganttColumn
90
  }
91

  
40 92
  #setupResizable() {
41 93
    const options = {
42 94
      handles: "e",
43 95
      minWidth: this.minWidthValue,
44 96
      zIndex: 30,
97
      start: () => {
98
        this.dispatch("resize-start")
99
      },
45 100
      resize: (_event, ui) => {
46 101
        this.element.style.setProperty("--gantt-column-width", `${ui.size.width}px`)
47 102
        this.element.style.removeProperty("width")
103
        this.dispatch("resize", { detail: { width: ui.size.width } })
104
      },
105
      stop: (_event, ui) => {
106
        if (ui.originalSize.width !== ui.size.width) {
107
          this.#saveWidth(ui.size.width)
108
        }
48 109
      }
49 110
    }
50 111

  
51 112
    this.#$element.resizable(options)
52 113
  }
53 114

  
115
  #restoreWidth() {
116
    const width = this.#columnWidthStore.get(this.#columnName)
117

  
118
    if (width !== null && width >= this.minWidthValue) {
119
      this.element.style.setProperty("--gantt-column-width", `${width}px`)
120
    }
121

  
122
    this.data.set("width-restored", "")
123
  }
124

  
125
  #saveWidth(width) {
126
    if (width < this.minWidthValue) return
127

  
128
    this.#columnWidthStore.set(this.#columnName, width)
129
  }
130

  
54 131
  #isMobile() {
55 132
    return !!(typeof window.isMobile === "function" && window.isMobile())
56 133
  }
app/views/gantts/_chart.html.erb
13 13
<% end %>
14 14

  
15 15
<%= gantt_chart_tag(@query, layout) do %>
16
  <%= gantt_column_tag('subjects', min_width: 100, width: 'var(--gantt-subject-width)') do %>
16
  <%= gantt_subject_column_tag do %>
17 17
    <section class="gantt-pane">
18 18
      <header aria-hidden="true"></header>
19 19
      <div class="gantt-pane-body" data-controller="gantt--subjects">
test/system/gantts_test.rb
107 107
    assert width_after > width_before
108 108
  end
109 109

  
110
  test 'column widths are remembered globally' do
111
    visit_gantt
112

  
113
    # Wait for column resizing to initialize before reading the width.
114
    assert_selector '.gantt-column[data-gantt-column="subjects"].ui-resizable'
115
    default_subject_width = column_width('subjects')
116

  
117
    drag_column_resizer('subjects', 60)
118
    resized_subject_width = column_width('subjects')
119
    assert resized_subject_width > default_subject_width
120

  
121
    page.refresh
122
    assert_selector '.gantt-column[data-gantt-column="subjects"].ui-resizable'
123
    assert_equal resized_subject_width, column_width('subjects')
124

  
125
    expand_options
126
    find('#draw_selected_columns').check
127
    drag_column_resizer('status', 80)
128
    resized_status_width = column_width('status')
129

  
130
    ['/projects/subproject1/issues/gantt', '/issues/gantt'].each do |path|
131
      visit path
132
      assert_selector '.gantt-column[data-gantt-column="subjects"].ui-resizable'
133
      assert_equal resized_subject_width, column_width('subjects')
134

  
135
      expand_options
136
      find('#draw_selected_columns').check
137
      assert_equal resized_status_width, column_width('status')
138
    end
139
  end
140

  
141
  test 'subject width adapts when the sidebar is opened and closed after manual resizing' do
142
    visit_gantt
143
    subject_column = '.gantt-column[data-gantt-column="subjects"]'
144
    assert_selector "#{subject_column}.ui-resizable"
145

  
146
    find('#sidebar-switch-button').click
147
    drag_column_resizer('subjects', 500)
148
    wide_width = column_width('subjects')
149

  
150
    find('#sidebar-switch-button').click
151
    assert_selector(subject_column) { column_width('subjects') < wide_width }
152
    narrow_width = column_width('subjects')
153

  
154
    find('#sidebar-switch-button').click
155
    assert_selector(subject_column) { column_width('subjects') > narrow_width }
156
  end
157

  
110 158
  test 'context menu and tooltip interactions' do
111 159
    visit_gantt
112 160

  
(3-3/3)