Extract Gantt view structure and wire Stimulus controllers (#43397).

Patch by Katsuya HIDAKA (user:hidakatsuya).


git-svn-id: https://svn.redmine.org/redmine/trunk@24085 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
Go MAEDA
2025-10-29 02:35:11 +00:00
parent 53ac36c3b8
commit 3775bb31d6
13 changed files with 1339 additions and 881 deletions

View File

@@ -0,0 +1,357 @@
import { Controller } from "@hotwired/stimulus"
const RELATION_STROKE_WIDTH = 2
export default class extends Controller {
static targets = ["ganttArea", "drawArea", "subjectsContainer"]
static values = {
issueRelationTypes: Object,
showSelectedColumns: Boolean,
showRelations: Boolean,
showProgress: Boolean
}
#drawTop = 0
#drawRight = 0
#drawLeft = 0
#drawPaper = null
initialize() {
this.$ = window.jQuery
this.Raphael = window.Raphael
}
connect() {
this.#drawTop = 0
this.#drawRight = 0
this.#drawLeft = 0
this.#drawProgressLineAndRelations()
this.#drawSelectedColumns()
}
disconnect() {
if (this.#drawPaper) {
this.#drawPaper.remove()
this.#drawPaper = null
}
}
showSelectedColumnsValueChanged() {
this.#drawSelectedColumns()
}
showRelationsValueChanged() {
this.#drawProgressLineAndRelations()
}
showProgressValueChanged() {
this.#drawProgressLineAndRelations()
}
handleWindowResize() {
this.#drawProgressLineAndRelations()
this.#drawSelectedColumns()
}
handleSubjectTreeChanged() {
this.#drawProgressLineAndRelations()
this.#drawSelectedColumns()
}
handleOptionsDisplay(event) {
this.showSelectedColumnsValue = !!(event.detail && event.detail.enabled)
}
handleOptionsRelations(event) {
this.showRelationsValue = !!(event.detail && event.detail.enabled)
}
handleOptionsProgress(event) {
this.showProgressValue = !!(event.detail && event.detail.enabled)
}
#drawProgressLineAndRelations() {
if (this.#drawPaper) {
this.#drawPaper.clear()
} else {
this.#drawPaper = this.Raphael(this.drawAreaTarget)
}
this.#setupDrawArea()
if (this.showProgressValue) {
this.#drawGanttProgressLines()
}
if (this.showRelationsValue) {
this.#drawRelations()
}
const content = document.getElementById("content")
if (content) {
content.classList.add("gantt_content")
}
}
#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 $subjectsContainer = this.$(".gantt_subjects_container")
const isMobileDevice = typeof window.isMobile === "function" && window.isMobile()
if (this.showSelectedColumnsValue) {
if (isMobileDevice) {
$selectedColumns.each((_, element) => {
this.$(element).hide()
})
} else {
$subjectsContainer.addClass("draw_selected_columns")
$selectedColumns.show()
}
} else {
$selectedColumns.each((_, element) => {
this.$(element).hide()
})
$subjectsContainer.removeClass("draw_selected_columns")
}
}
get #relationsArray() {
const relations = []
this.$("div.task_todo[data-rels]").each((_, element) => {
const $element = this.$(element)
if (!$element.is(":visible")) return
const elementId = $element.attr("id")
if (!elementId) return
const issueId = elementId.replace("task-todo-issue-", "")
const dataRels = $element.data("rels") || {}
Object.keys(dataRels).forEach((relTypeKey) => {
this.$.each(dataRels[relTypeKey], (_, relatedIssue) => {
relations.push({ issue_from: issueId, issue_to: relatedIssue, rel_type: relTypeKey })
})
})
})
return relations
}
#drawRelations() {
const relations = this.#relationsArray
relations.forEach((relation) => {
const issueFrom = this.$(`#task-todo-issue-${relation.issue_from}`)
const issueTo = this.$(`#task-todo-issue-${relation.issue_to}`)
if (issueFrom.length === 0 || issueTo.length === 0) return
const issueHeight = issueFrom.height()
const issueFromTop = issueFrom.position().top + issueHeight / 2 - this.#drawTop
const issueFromRight = issueFrom.position().left + issueFrom.width()
const issueToTop = issueTo.position().top + issueHeight / 2 - this.#drawTop
const issueToLeft = issueTo.position().left
const relationConfig = this.issueRelationTypesValue[relation.rel_type] || {}
const color = relationConfig.color || "#000"
const landscapeMargin = relationConfig.landscape_margin || 0
const issueFromRightRel = issueFromRight + landscapeMargin
const issueToLeftRel = issueToLeft - landscapeMargin
this.#drawPaper
.path([
"M",
issueFromRight + this.#drawLeft,
issueFromTop,
"L",
issueFromRightRel + this.#drawLeft,
issueFromTop
])
.attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH })
if (issueFromRightRel < issueToLeftRel) {
this.#drawPaper
.path([
"M",
issueFromRightRel + this.#drawLeft,
issueFromTop,
"L",
issueFromRightRel + this.#drawLeft,
issueToTop
])
.attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH })
this.#drawPaper
.path([
"M",
issueFromRightRel + this.#drawLeft,
issueToTop,
"L",
issueToLeft + this.#drawLeft,
issueToTop
])
.attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH })
} else {
const issueMiddleTop = issueToTop + issueHeight * (issueFromTop > issueToTop ? 1 : -1)
this.#drawPaper
.path([
"M",
issueFromRightRel + this.#drawLeft,
issueFromTop,
"L",
issueFromRightRel + this.#drawLeft,
issueMiddleTop
])
.attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH })
this.#drawPaper
.path([
"M",
issueFromRightRel + this.#drawLeft,
issueMiddleTop,
"L",
issueToLeftRel + this.#drawLeft,
issueMiddleTop
])
.attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH })
this.#drawPaper
.path([
"M",
issueToLeftRel + this.#drawLeft,
issueMiddleTop,
"L",
issueToLeftRel + this.#drawLeft,
issueToTop
])
.attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH })
this.#drawPaper
.path([
"M",
issueToLeftRel + this.#drawLeft,
issueToTop,
"L",
issueToLeft + this.#drawLeft,
issueToTop
])
.attr({ stroke: color, "stroke-width": RELATION_STROKE_WIDTH })
}
this.#drawPaper
.path([
"M",
issueToLeft + this.#drawLeft,
issueToTop,
"l",
-4 * RELATION_STROKE_WIDTH,
-2 * RELATION_STROKE_WIDTH,
"l",
0,
4 * RELATION_STROKE_WIDTH,
"z"
])
.attr({
stroke: "none",
fill: color,
"stroke-linecap": "butt",
"stroke-linejoin": "miter"
})
})
}
get #progressLinesArray() {
const lines = []
const todayLeft = this.$("#today_line").position().left
lines.push({ left: todayLeft, top: 0 })
this.$("div.issue-subject, div.version-name").each((_, element) => {
const $element = this.$(element)
if (!$element.is(":visible")) return true
const topPosition = $element.position().top - this.#drawTop
const elementHeight = $element.height() / 9
const elementTopUpper = topPosition - elementHeight
const elementTopCenter = topPosition + elementHeight * 3
const elementTopLower = topPosition + elementHeight * 8
const issueClosed = $element.children("span").hasClass("issue-closed")
const versionClosed = $element.children("span").hasClass("version-closed")
if (issueClosed || versionClosed) {
lines.push({ left: todayLeft, top: elementTopCenter })
} else {
const issueDone = this.$(`#task-done-${$element.attr("id")}`)
const isBehindStart = $element.children("span").hasClass("behind-start-date")
const isOverEnd = $element.children("span").hasClass("over-end-date")
if (isOverEnd) {
lines.push({ left: this.#drawRight, top: elementTopUpper, is_right_edge: true })
lines.push({
left: this.#drawRight,
top: elementTopLower,
is_right_edge: true,
none_stroke: true
})
} else if (issueDone.length > 0) {
const doneLeft = issueDone.first().position().left + issueDone.first().width()
lines.push({ left: doneLeft, top: elementTopCenter })
} else if (isBehindStart) {
lines.push({ left: 0, top: elementTopUpper, is_left_edge: true })
lines.push({
left: 0,
top: elementTopLower,
is_left_edge: true,
none_stroke: true
})
} else {
let todoLeft = todayLeft
const issueTodo = this.$(`#task-todo-${$element.attr("id")}`)
if (issueTodo.length > 0) {
todoLeft = issueTodo.first().position().left
}
lines.push({ left: Math.min(todayLeft, todoLeft), top: elementTopCenter })
}
}
})
return lines
}
#drawGanttProgressLines() {
if (this.$("#today_line").length === 0) return
const progressLines = this.#progressLinesArray
const color = this.$("#today_line").css("border-left-color") || "#ff0000"
for (let index = 1; index < progressLines.length; index += 1) {
const current = progressLines[index]
const previous = progressLines[index - 1]
if (
!current.none_stroke &&
!(
(previous.is_right_edge && current.is_right_edge) ||
(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
this.#drawPaper
.path(["M", x1, previous.top, "L", x2, current.top])
.attr({ stroke: color, "stroke-width": 2 })
}
}
}
}

View File

@@ -0,0 +1,76 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
minWidth: Number,
column: String,
// Local value
mobileMode: { type: Boolean, default: false }
}
#$element = null
initialize() {
this.$ = window.jQuery
}
connect() {
this.#$element = this.$(this.element)
this.#setupResizable()
this.#dispatchResizeColumn()
}
disconnect() {
this.#$element?.resizable("destroy")
this.#$element = null
}
handleWindowResize(_event) {
this.mobileModeValue = this.#isMobile()
this.#dispatchResizeColumn()
}
mobileModeValueChanged(current, old) {
if (current == old) return
if (this.mobileModeValue) {
this.#$element?.resizable("disable")
} else {
this.#$element?.resizable("enable")
}
}
#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")
}
}
this.#$element
.resizable(options)
.on("resize", (event) => {
event.stopPropagation()
this.#dispatchResizeColumn()
})
}
#dispatchResizeColumn() {
if (!this.#$element) return
this.dispatch(`resize-column-${this.columnValue}`, { detail: { width: this.#$element.width() } })
}
#isMobile() {
return !!(typeof window.isMobile === "function" && window.isMobile())
}
}

View File

@@ -0,0 +1,63 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["display", "relations", "progress"]
static values = {
unavailableColumns: Array
}
initialize() {
this.$ = window.jQuery
}
connect() {
this.#dispatchInitialStates()
this.#disableUnavailableColumns()
}
toggleDisplay(event) {
this.dispatch("toggle-display", {
detail: { enabled: event.currentTarget.checked }
})
}
toggleRelations(event) {
this.dispatch("toggle-relations", {
detail: { enabled: event.currentTarget.checked }
})
}
toggleProgress(event) {
this.dispatch("toggle-progress", {
detail: { enabled: event.currentTarget.checked }
})
}
#dispatchInitialStates() {
if (this.hasDisplayTarget) {
this.dispatch("toggle-display", {
detail: { enabled: this.displayTarget.checked }
})
}
if (this.hasRelationsTarget) {
this.dispatch("toggle-relations", {
detail: { enabled: this.relationsTarget.checked }
})
}
if (this.hasProgressTarget) {
this.dispatch("toggle-progress", {
detail: { enabled: this.progressTarget.checked }
})
}
}
#disableUnavailableColumns() {
if (!Array.isArray(this.unavailableColumnsValue)) {
return
}
this.unavailableColumnsValue.forEach((column) => {
this.$("#available_c, #selected_c").children(`[value='${column}']`).prop("disabled", true)
})
}
}

View File

@@ -0,0 +1,122 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
initialize() {
this.$ = window.jQuery
}
handleResizeColumn(event) {
const columnWidth = event.detail.width;
this.$(".issue-subject, .project-name, .version-name").each((_, element) => {
const $element = this.$(element)
$element.width(columnWidth - $element.position().left)
})
}
handleEntryClick(event) {
const iconExpander = event.currentTarget
const $subject = this.$(iconExpander.parentElement)
const subjectLeft =
parseInt($subject.css("left"), 10) + parseInt(iconExpander.offsetWidth, 10)
let targetShown = null
let targetTop = 0
let totalHeight = 0
let outOfHierarchy = false
const willOpen = !$subject.hasClass("open")
this.#setIconState($subject, willOpen)
$subject.nextAll("div").each((_, element) => {
const $element = this.$(element)
const json = $element.data("collapse-expand")
const numberOfRows = $element.data("number-of-rows")
const barsSelector = `#gantt_area 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}']`
if (outOfHierarchy || parseInt($element.css("left"), 10) <= subjectLeft) {
outOfHierarchy = true
if (targetShown === null) return false
const newTopVal = parseInt($element.css("top"), 10) + totalHeight * (targetShown ? -1 : 1)
$element.css("top", newTopVal)
this.$([barsSelector, selectedColumnsSelector].join()).each((__, el) => {
this.$(el).css("top", newTopVal)
})
return true
}
const isShown = $element.is(":visible")
if (targetShown === null) {
targetShown = isShown
targetTop = parseInt($element.css("top"), 10)
totalHeight = 0
}
if (isShown === targetShown) {
this.$(barsSelector).each((__, task) => {
const $task = this.$(task)
if (!isShown && willOpen) {
$task.css("top", targetTop + totalHeight)
}
if (!$task.hasClass("tooltip")) {
$task.toggle(willOpen)
}
})
this.$(selectedColumnsSelector).each((__, attr) => {
const $attr = this.$(attr)
if (!isShown && willOpen) {
$attr.css("top", targetTop + totalHeight)
}
$attr.toggle(willOpen)
})
if (!isShown && willOpen) {
$element.css("top", targetTop + totalHeight)
}
this.#setIconState($element, willOpen)
$element.toggle(willOpen)
totalHeight += parseInt(json.top_increment, 10)
}
})
this.dispatch("toggle-tree", { bubbles: true })
}
#setIconState(element, open) {
const $element = element.jquery ? element : this.$(element)
const expander = $element.find(".expander")
if (open) {
$element.addClass("open")
if (expander.length > 0) {
expander.removeClass("icon-collapsed").addClass("icon-expanded")
if (expander.find("svg").length === 1) {
window.updateSVGIcon(expander[0], "angle-down")
}
}
} else {
$element.removeClass("open")
if (expander.length > 0) {
expander.removeClass("icon-expanded").addClass("icon-collapsed")
if (expander.find("svg").length === 1) {
window.updateSVGIcon(expander[0], "angle-right")
}
}
}
}
}