mirror of
https://github.com/redmine/redmine.git
synced 2026-01-24 00:09:46 +01:00
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:
357
app/javascript/controllers/gantt/chart_controller.js
Normal file
357
app/javascript/controllers/gantt/chart_controller.js
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
app/javascript/controllers/gantt/column_controller.js
Normal file
76
app/javascript/controllers/gantt/column_controller.js
Normal 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())
|
||||
}
|
||||
}
|
||||
63
app/javascript/controllers/gantt/options_controller.js
Normal file
63
app/javascript/controllers/gantt/options_controller.js
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
122
app/javascript/controllers/gantt/subjects_controller.js
Normal file
122
app/javascript/controllers/gantt/subjects_controller.js
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user