diff --git a/js-src/14-gantt.js b/js-src/14-gantt.js index 8177626..d7caec3 100644 --- a/js-src/14-gantt.js +++ b/js-src/14-gantt.js @@ -17,6 +17,9 @@ var svgId = config.svgId || (instanceId + '-svg'); var ganttChart = null; var splitInstance = null; + var mounted = false; + var mountTarget = null; + var resizeObserver = null; var tasks = []; // ── Build DOM ── @@ -82,8 +85,21 @@ sf.assert(parent, 'gantt.mount(parent) requires a mount target'); var target = typeof parent === 'string' ? document.getElementById(parent) : parent; sf.assert(target, 'gantt.mount(parent) target not found: ' + parent); + validateMountTarget(target); + + if (mounted && mountTarget === target && wrapper.parentNode === target) { + return; + } + if (mounted) ctrl.destroy(); target.appendChild(wrapper); + mounted = true; + mountTarget = target; + if (tasks.length > 0 || grid.firstChild || chartContainer.firstChild) { + renderGrid(tasks); + renderChart(tasks); + } initSplit(); + bindResizeObserver(); }; ctrl.setTasks = function (newTasks) { @@ -124,8 +140,14 @@ }; ctrl.destroy = function () { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } if (splitInstance) { splitInstance.destroy(); splitInstance = null; } ganttChart = null; + mounted = false; + mountTarget = null; if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper); }; @@ -135,10 +157,18 @@ function initSplit() { if (typeof Split !== 'function') return; + if (splitInstance) { + splitInstance.destroy(); + splitInstance = null; + } + + var splitSizes = normalizePair(config.splitSizes, [40, 60]); + var splitMinSize = normalizePair(config.splitMinSize, [200, 300]); + splitInstance = Split(['#' + gridPaneId, '#' + chartPaneId], { direction: 'vertical', - sizes: config.splitSizes || [40, 60], - minSize: config.splitMinSize || [200, 300], + sizes: splitSizes, + minSize: splitMinSize, snapOffset: 30, gutterSize: 4, cursor: 'col-resize', @@ -150,6 +180,46 @@ }); } + function bindResizeObserver() { + if (typeof ResizeObserver !== 'function') return; + if (resizeObserver) { + resizeObserver.disconnect(); + } + resizeObserver = new ResizeObserver(function () { + if (!ganttChart) return; + setTimeout(function () { ganttChart.refresh(tasksToFrappe(tasks)); }, 0); + }); + if (wrapper.parentNode) resizeObserver.observe(wrapper.parentNode); + } + + function normalizePair(value, fallback) { + if (typeof value === 'number' && isFinite(value)) return [value, value]; + if (!Array.isArray(value) || value.length !== 2) return fallback.slice(); + var n0 = Number(value[0]); + var n1 = Number(value[1]); + if (!isFinite(n0) || !isFinite(n1)) return fallback.slice(); + return [n0, n1]; + } + + function validateMountTarget(target) { + sf.assert(target && typeof target.appendChild === 'function', 'gantt.mount(parent) requires a valid DOM container'); + sf.assert(getElementSize(target, 'Width') > 0 && getElementSize(target, 'Height') > 0, 'gantt.mount(parent) target is not laid out yet'); + } + + function getElementSize(target, axis) { + var clientKey = 'client' + axis; + var offsetKey = 'offset' + axis; + var rectKey = axis === 'Width' ? 'width' : 'height'; + + if (typeof target[clientKey] === 'number') return target[clientKey]; + if (typeof target[offsetKey] === 'number') return target[offsetKey]; + if (typeof target.getBoundingClientRect === 'function') { + var rect = target.getBoundingClientRect(); + if (rect && typeof rect[rectKey] === 'number') return rect[rectKey]; + } + return 0; + } + function tasksToFrappe(taskList) { return taskList .filter(function (t) { return t.start && t.end; }) diff --git a/static/sf/sf.js b/static/sf/sf.js index 7e4db60..553eb43 100644 --- a/static/sf/sf.js +++ b/static/sf/sf.js @@ -1309,6 +1309,9 @@ const SF = (function () { var svgId = config.svgId || (instanceId + '-svg'); var ganttChart = null; var splitInstance = null; + var mounted = false; + var mountTarget = null; + var resizeObserver = null; var tasks = []; // ── Build DOM ── @@ -1374,8 +1377,21 @@ const SF = (function () { sf.assert(parent, 'gantt.mount(parent) requires a mount target'); var target = typeof parent === 'string' ? document.getElementById(parent) : parent; sf.assert(target, 'gantt.mount(parent) target not found: ' + parent); + validateMountTarget(target); + + if (mounted && mountTarget === target && wrapper.parentNode === target) { + return; + } + if (mounted) ctrl.destroy(); target.appendChild(wrapper); + mounted = true; + mountTarget = target; + if (tasks.length > 0 || grid.firstChild || chartContainer.firstChild) { + renderGrid(tasks); + renderChart(tasks); + } initSplit(); + bindResizeObserver(); }; ctrl.setTasks = function (newTasks) { @@ -1416,8 +1432,14 @@ const SF = (function () { }; ctrl.destroy = function () { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } if (splitInstance) { splitInstance.destroy(); splitInstance = null; } ganttChart = null; + mounted = false; + mountTarget = null; if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper); }; @@ -1427,10 +1449,18 @@ const SF = (function () { function initSplit() { if (typeof Split !== 'function') return; + if (splitInstance) { + splitInstance.destroy(); + splitInstance = null; + } + + var splitSizes = normalizePair(config.splitSizes, [40, 60]); + var splitMinSize = normalizePair(config.splitMinSize, [200, 300]); + splitInstance = Split(['#' + gridPaneId, '#' + chartPaneId], { direction: 'vertical', - sizes: config.splitSizes || [40, 60], - minSize: config.splitMinSize || [200, 300], + sizes: splitSizes, + minSize: splitMinSize, snapOffset: 30, gutterSize: 4, cursor: 'col-resize', @@ -1442,6 +1472,46 @@ const SF = (function () { }); } + function bindResizeObserver() { + if (typeof ResizeObserver !== 'function') return; + if (resizeObserver) { + resizeObserver.disconnect(); + } + resizeObserver = new ResizeObserver(function () { + if (!ganttChart) return; + setTimeout(function () { ganttChart.refresh(tasksToFrappe(tasks)); }, 0); + }); + if (wrapper.parentNode) resizeObserver.observe(wrapper.parentNode); + } + + function normalizePair(value, fallback) { + if (typeof value === 'number' && isFinite(value)) return [value, value]; + if (!Array.isArray(value) || value.length !== 2) return fallback.slice(); + var n0 = Number(value[0]); + var n1 = Number(value[1]); + if (!isFinite(n0) || !isFinite(n1)) return fallback.slice(); + return [n0, n1]; + } + + function validateMountTarget(target) { + sf.assert(target && typeof target.appendChild === 'function', 'gantt.mount(parent) requires a valid DOM container'); + sf.assert(getElementSize(target, 'Width') > 0 && getElementSize(target, 'Height') > 0, 'gantt.mount(parent) target is not laid out yet'); + } + + function getElementSize(target, axis) { + var clientKey = 'client' + axis; + var offsetKey = 'offset' + axis; + var rectKey = axis === 'Width' ? 'width' : 'height'; + + if (typeof target[clientKey] === 'number') return target[clientKey]; + if (typeof target[offsetKey] === 'number') return target[offsetKey]; + if (typeof target.getBoundingClientRect === 'function') { + var rect = target.getBoundingClientRect(); + if (rect && typeof rect[rectKey] === 'number') return rect[rectKey]; + } + return 0; + } + function tasksToFrappe(taskList) { return taskList .filter(function (t) { return t.start && t.end; }) diff --git a/tests/instance-local-dom.test.js b/tests/instance-local-dom.test.js index 8c7ee8d..95b0857 100644 --- a/tests/instance-local-dom.test.js +++ b/tests/instance-local-dom.test.js @@ -158,3 +158,108 @@ test('gantt.create falls back to built-in defaults when config is omitted', () = assert.equal(Boolean(panes[1].id), true); assert.equal(Boolean(chartContainer.id), true); }); + +test('gantt remount recreates the chart and preserves refresh behavior', () => { + const splitCalls = []; + const refreshCalls = []; + let ganttInstanceCount = 0; + + const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], { + Split: function (targets, options) { + splitCalls.push({ targets, options }); + return { + destroy() {}, + }; + }, + Gantt: function () { + ganttInstanceCount++; + return { + change_view_mode() {}, + refresh(tasks) { + refreshCalls.push(tasks); + }, + }; + }, + }); + + const mountOne = document.createElement('div'); + const mountTwo = document.createElement('div'); + document.body.appendChild(mountOne); + document.body.appendChild(mountTwo); + + const gantt = SF.gantt.create({}); + gantt.setTasks([{ id: 'task-1', start: '2026-03-21', end: '2026-03-22' }]); + gantt.mount(mountOne); + gantt.mount(mountTwo); + gantt.refresh(); + + assert.equal(ganttInstanceCount >= 2, true); + assert.equal(mountOne.childNodes.includes(gantt.el), false); + assert.equal(mountTwo.childNodes.includes(gantt.el), true); + assert.notEqual(gantt.getChart(), null); + assert.equal(refreshCalls.length, 1); + assert.equal(splitCalls.length, 2); +}); + +test('failed gantt remount keeps the existing mounted chart intact', () => { + let destroyCount = 0; + + const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], { + Split: function () { + return { + destroy() { + destroyCount++; + }, + }; + }, + Gantt: function () { + return { + change_view_mode() {}, + refresh() {}, + }; + }, + }); + + const validMount = document.createElement('div'); + const hiddenMount = document.createElement('div'); + hiddenMount.clientWidth = 0; + hiddenMount.clientHeight = 0; + hiddenMount.offsetWidth = 0; + hiddenMount.offsetHeight = 0; + document.body.appendChild(validMount); + document.body.appendChild(hiddenMount); + + const gantt = SF.gantt.create({}); + gantt.setTasks([{ id: 'task-1', start: '2026-03-21', end: '2026-03-22' }]); + gantt.mount(validMount); + + assert.throws(function () { + gantt.mount(hiddenMount); + }, /target is not laid out yet/); + assert.equal(validMount.childNodes.includes(gantt.el), true); + assert.equal(hiddenMount.childNodes.includes(gantt.el), false); + assert.equal(destroyCount, 0); +}); + +test('gantt initSplit keeps accepting scalar splitMinSize values', () => { + const splitCalls = []; + + const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], { + Split: function (targets, options) { + splitCalls.push({ targets, options }); + return { + destroy() {}, + }; + }, + }); + + const mount = document.createElement('div'); + document.body.appendChild(mount); + + const gantt = SF.gantt.create({ splitMinSize: 160 }); + gantt.mount(mount); + + assert.equal(splitCalls.length, 1); + assert.equal(splitCalls[0].options.minSize[0], 160); + assert.equal(splitCalls[0].options.minSize[1], 160); +}); diff --git a/tests/support/fake-dom.js b/tests/support/fake-dom.js index 5ade7ea..23b4b0d 100644 --- a/tests/support/fake-dom.js +++ b/tests/support/fake-dom.js @@ -79,6 +79,10 @@ class FakeElement extends FakeNode { this._className = ''; this._id = ''; this._innerHTML = ''; + this.clientWidth = 1024; + this.clientHeight = 768; + this.offsetWidth = 1024; + this.offsetHeight = 768; } _syncClassName() { @@ -174,6 +178,17 @@ class FakeElement extends FakeNode { }); return matches; } + + getBoundingClientRect() { + return { + width: this.clientWidth, + height: this.clientHeight, + top: 0, + left: 0, + right: this.clientWidth, + bottom: this.clientHeight, + }; + } } class FakeDocument {