Feature #44608 » 0001-Remember-Gantt-column-widths.patch
| 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 | |
- « Previous
- 1
- 2
- 3
- Next »