diff --git a/app/assets/javascripts/gantt.js b/app/assets/javascripts/gantt.js index 6a42e5be9..09f173655 100644 --- a/app/assets/javascripts/gantt.js +++ b/app/assets/javascripts/gantt.js @@ -233,79 +233,184 @@ function resizableSubjectColumn(){ }; } -ganttEntryClick = function(e){ - var icon_expander = e.currentTarget; - var subject = $(icon_expander.parentElement); - var subject_left = parseInt(subject.css('left')) + parseInt(icon_expander.offsetWidth); - var target_shown = null; - var target_top = 0; - var total_height = 0; - var out_of_hierarchy = false; - var iconChange = null; - if(subject.hasClass('open')) - iconChange = function(element){ - var expander = $(element).find('.expander') - expander.switchClass('icon-expanded', 'icon-collapsed'); - $(element).removeClass('open'); - if (expander.find('svg').length === 1) { - updateSVGIcon(expander[0], 'angle-right') +ganttEntryClick = function (e) { + const iconExpanderElem = e.currentTarget; + const subjectElem = iconExpanderElem.parentElement; + const expanderWidth = iconExpanderElem.offsetWidth; + const recursive = e.ctrlKey; + + function toggleClass(elem, class1, class2) { + if (!elem) return; + elem.classList.remove(class1); + elem.classList.add(class2); + } + + function getLeft(elem) { + if (!elem) return 0; + return elem.offsetLeft || parseInt(elem.style.left); + } + + function getTop(elem) { + if (!elem) return 0; + return elem.offsetTop || parseInt(elem.style.top); + } + + function nextAll(elem, tagName) { + const nextAllElems = []; + let targetElem = elem.nextElementSibling; + while (targetElem) { + if (!tagName || targetElem.tagName === tagName) { + nextAllElems.push(targetElem); } - }; - else - iconChange = function(element){ - var expander = $(element).find('.expander') - expander.find('.expander').switchClass('icon-collapsed', 'icon-expanded'); - $(element).addClass('open'); - if (expander.find('svg').length === 1) { - updateSVGIcon(expander[0], 'angle-down') + targetElem = targetElem.nextElementSibling; + } + return nextAllElems; + } + + class GanttItem { + constructor(elem) { + this.elem = elem; + this.iconExpander = elem.querySelector(":scope > .icon.expander"); + this.left = + getLeft(this.elem) + (this.iconExpander ? expanderWidth : 0); + this.top = getTop(this.elem); + this.isShown = + this.elem.offsetWidth > 0 || this.elem.offsetHeight > 0; + this.json = JSON.parse(this.elem.dataset.collapseExpand); + this.numberOfRows = this.elem.dataset.numberOfRows; + this.isCollapsed = + this.iconExpander?.classList.contains("icon-collapsed"); + + const selector = + `[data-collapse-expand="${this.json.obj_id}"]` + + `[data-number-of-rows="${this.numberOfRows}"]`; + this.taskBars = document.querySelectorAll( + `#gantt_area form > ${selector}` + ); + this.selectedColumns = document.querySelectorAll( + `td.gantt_selected_column ${selector}` + ); + } + + toggleIcon(force = undefined) { + if (this.isCollapsed === undefined) return false; + + this.elem.classList.remove("open"); + const svgIcon = this.iconExpander.getElementsByTagName("svg"); + + if ((force === true && !(force === false)) || this.isCollapsed) { + toggleClass(this.iconExpander, "icon-collapsed", "icon-expanded"); + this.elem.classList.add("open"); + if (svgIcon.length === 1) { + updateSVGIcon(this.iconExpander, 'angle-down'); + } + } else { + toggleClass(this.iconExpander, "icon-expanded", "icon-collapsed"); + if (svgIcon.length === 1) { + updateSVGIcon(this.iconExpander, 'angle-right'); + } } - }; - iconChange(subject); - subject.nextAll('div').each(function(_, element){ - var el = $(element); - var json = el.data('collapse-expand'); - var number_of_rows = el.data('number-of-rows'); - var el_task_bars = '#gantt_area form > div[data-collapse-expand="' + json.obj_id + '"][data-number-of-rows="' + number_of_rows + '"]'; - var el_selected_columns = 'td.gantt_selected_column div[data-collapse-expand="' + json.obj_id + '"][data-number-of-rows="' + number_of_rows + '"]'; - if(out_of_hierarchy || parseInt(el.css('left')) <= subject_left){ - out_of_hierarchy = true; - if(target_shown == null) return false; - - var new_top_val = parseInt(el.css('top')) + total_height * (target_shown ? -1 : 1); - el.css('top', new_top_val); - $([el_task_bars, el_selected_columns].join()).each(function(_, el){ - $(el).css('top', new_top_val); - }); + + this.isCollapsed = !this.isCollapsed; return true; } - var is_shown = el.is(':visible'); - if(target_shown == null){ - target_shown = is_shown; - target_top = parseInt(el.css('top')); - total_height = 0; + #setDisplayStyle(displayStyle) { + this.taskBars.forEach((taskBar) => { + taskBar.style.display = displayStyle; + }); + this.selectedColumns.forEach((selectedColumn) => { + selectedColumn.style.display = displayStyle; + }); + this.elem.style.display = displayStyle; + } + + hide() { + this.#setDisplayStyle("none"); + this.isShown = false; + } + + show() { + this.#setDisplayStyle(""); + this.isShown = true; } - if(is_shown == target_shown){ - $(el_task_bars).each(function(_, task) { - var el_task = $(task); - if(!is_shown) - el_task.css('top', target_top + total_height); - if(!el_task.hasClass('tooltip')) - el_task.toggle(!is_shown); + + move(top) { + this.top = top; + this.taskBars.forEach((taskBar) => { + taskBar.style.top = `${top}px`; }); - $(el_selected_columns).each(function (_, attr) { - var el_attr = $(attr); - if (!is_shown) - el_attr.css('top', target_top + total_height); - el_attr.toggle(!is_shown); + this.selectedColumns.forEach((selectedColumn) => { + selectedColumn.style.top = `${top}px`; }); - if(!is_shown) - el.css('top', target_top + total_height); - iconChange(el); - el.toggle(!is_shown); - total_height += parseInt(json.top_increment); + this.elem.style.top = `${top}px`; } - }); + } + + const subject = new GanttItem(subjectElem); + subject.toggleIcon(); + + let totalHeight = 0; + let outOfHierarchyTop = null; + let firstItemTop = null; + let collapsedStateHierarchy = new Map(); + collapsedStateHierarchy.set(subject.left, subject.isCollapsed); + let prevItemLeft = subject.left; + + function updateGanttItemPositionAndView (ganttItem) { + if (outOfHierarchyTop || ganttItem.left <= subject.left) { + if (!outOfHierarchyTop) outOfHierarchyTop = ganttItem.top; + + const newTop = + ganttItem.top + + (subject.isCollapsed + ? -outOfHierarchyTop + subject.top + subject.json.top_increment + : totalHeight); + + ganttItem.move(newTop); + return; + } + + // Clear the collapsed state for levels deeper than the current hierarchy + // level. + if (prevItemLeft > ganttItem.left) { + for (const left of collapsedStateHierarchy.keys()) { + if (left >= ganttItem.left) collapsedStateHierarchy.delete(left); + } + } + + // Update the stored left value for the next loop + prevItemLeft = ganttItem.left; + + if (!firstItemTop) { + firstItemTop = subject.top + subject.json.top_increment; + } + + if ( + (recursive && subject.isCollapsed) || + (!recursive && collapsedStateHierarchy.values().some((i) => i)) + ) { + if (ganttItem.isShown) ganttItem.hide(); + } else { + if (!ganttItem.isShown) ganttItem.show(); + ganttItem.move(firstItemTop + totalHeight); + totalHeight += ganttItem.json.top_increment; + } + + if (ganttItem.iconExpander) { + collapsedStateHierarchy.set(ganttItem.left, ganttItem.isCollapsed); + if (recursive && ganttItem.isCollapsed !== subject.isCollapsed) { + ganttItem.toggleIcon(); + } + } + } + + // Get all subsequent DIV elements, convert to GanttItem, + // and update their positions and view states. + nextAll(subjectElem, "DIV") + .map((elem) => new GanttItem(elem)) + .forEach(updateGanttItemPositionAndView); + drawGanttHandler(); };