diff --git a/README.md b/README.md index d856891..ad53740 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ Gauge styles: `heat` (blue→amber→red), `load` (emerald→amber→red), `emer Interactive task scheduling with Frappe Gantt. Split-pane layout: task grid on top, SVG timeline chart on bottom. Drag to reschedule, resize to change -duration, project-colored bars, dependency arrows. +duration, sortable grid columns, project-colored bars, and pinned task styling. ```html @@ -236,9 +236,9 @@ var gantt = SF.gantt.create({ viewMode: 'Quarter Day', splitSizes: [40, 60], columns: [ - { key: 'name', label: 'Task' }, - { key: 'start', label: 'Start' }, - { key: 'end', label: 'End' }, + { key: 'name', label: 'Task', sortable: true }, + { key: 'start', label: 'Start', sortable: true }, + { key: 'end', label: 'End', sortable: true }, { key: 'priority', label: 'P', render: function (t) { return { unsafeHtml: 'P' + t.priority + '', @@ -258,6 +258,8 @@ gantt.setTasks([ start: '2026-03-15 09:00', end: '2026-03-15 10:30', priority: 1, + projectIndex: 0, + pinned: true, custom_class: 'project-color-0 priority-1', dependencies: '', }, @@ -277,6 +279,7 @@ gantt.highlightTask('task-1'); ``` View modes: `Quarter Day`, `Half Day`, `Day`, `Week`, `Month`. +Sortable headers are opt-in per column via `sortable: true`. ## Backend Adapters diff --git a/css-src/17-gantt-layout.css b/css-src/17-gantt-layout.css index b6368a9..78f558f 100644 --- a/css-src/17-gantt-layout.css +++ b/css-src/17-gantt-layout.css @@ -94,15 +94,6 @@ border-radius: var(--sf-radius-sm); } -/* ── Maximized Pane ── */ -.sf-gantt-pane.maximized { - position: absolute !important; - inset: 0 !important; - z-index: 50; - height: 100% !important; - width: 100% !important; -} - /* ── Grid Table ── */ .sf-gantt-grid { height: 100%; diff --git a/css-src/18-gantt-bars.css b/css-src/18-gantt-bars.css index b15795e..f7bc917 100644 --- a/css-src/18-gantt-bars.css +++ b/css-src/18-gantt-bars.css @@ -20,13 +20,6 @@ .gantt .bar-wrapper.priority-2 .bar { opacity: 0.85; } .gantt .bar-wrapper.priority-3 .bar { opacity: 0.7; } -/* Cross-project dependency arrows */ -.gantt .arrow path.cross-project-arrow { - stroke: var(--sf-emerald-500); - stroke-dasharray: 5 3; - stroke-width: 1.4; -} - /* Bar hover glow */ .gantt .bar-wrapper:hover .bar { filter: brightness(1.12) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.22)); @@ -58,48 +51,6 @@ width 0.12s var(--sf-ease-out); } -/* Dragging state */ -.gantt .bar-wrapper.dragging { - opacity: 0.88; - filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.35)); - z-index: 100; - transition: none !important; -} - -.gantt .bar-wrapper.dragging .bar { - stroke-width: 2; - stroke: rgba(255, 255, 255, 0.7); -} - -body.sf-gantt-dragging { - cursor: grabbing !important; - user-select: none !important; -} - -body.sf-gantt-dragging * { - cursor: grabbing !important; -} - -/* ── Drag Tooltip ── */ -.sf-gantt-drag-tooltip { - position: fixed; - z-index: 9999; - background: rgba(15, 23, 42, 0.92); - color: #f1f5f9; - font-size: 11.5px; - font-family: var(--sf-font-mono); - font-weight: 500; - padding: 5px 10px; - border-radius: 6px; - pointer-events: none; - white-space: nowrap; - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); - backdrop-filter: blur(4px); - border: 1px solid rgba(255, 255, 255, 0.12); - letter-spacing: 0.02em; - display: none; -} - /* ── Pinned Task Indicators ── */ .gantt .bar-wrapper.pinned .bar { stroke: var(--sf-amber-500); @@ -107,12 +58,6 @@ body.sf-gantt-dragging * { stroke-dasharray: 6 3; } -.sf-gantt-pin-icon { - color: var(--sf-amber-500); - font-size: 11px; - opacity: 0.85; -} - /* ── Highlighted task (pulse from grid click) ── */ .gantt .bar-wrapper.highlighted .bar { animation: sf-gantt-pulse 0.5s ease-in-out 3; @@ -149,40 +94,6 @@ body.sf-gantt-dragging * { color: var(--sf-gray-500); } -/* ── Project Badge (grid table) ── */ -.sf-project-badge { - display: inline-flex; - align-items: center; - padding: var(--sf-badge-padding-y) var(--sf-badge-padding-x); - padding-left: 20px; - border-radius: var(--sf-badge-radius); - font-size: var(--sf-badge-font-size); - font-weight: var(--sf-badge-font-weight); - position: relative; - background: var(--sf-gray-100); -} - -.sf-project-badge::before { - content: ''; - position: absolute; - left: 7px; - top: 50%; - transform: translateY(-50%); - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; -} - -.sf-project-badge.sf-project-0 { color: var(--sf-project-0); background: var(--sf-project-0-light); } -.sf-project-badge.sf-project-1 { color: var(--sf-project-1); background: var(--sf-project-1-light); } -.sf-project-badge.sf-project-2 { color: var(--sf-project-2); background: var(--sf-project-2-light); } -.sf-project-badge.sf-project-3 { color: var(--sf-project-3); background: var(--sf-project-3-light); } -.sf-project-badge.sf-project-4 { color: var(--sf-project-4); background: var(--sf-project-4-light); } -.sf-project-badge.sf-project-5 { color: var(--sf-project-5); background: var(--sf-project-5-light); } -.sf-project-badge.sf-project-6 { color: var(--sf-project-6); background: var(--sf-project-6-light); } -.sf-project-badge.sf-project-7 { color: var(--sf-project-7); background: var(--sf-project-7-light); } - /* ── Gantt Popup ── */ .sf-gantt-popup { padding: 8px 12px; diff --git a/js-src/14-gantt.js b/js-src/14-gantt.js index d7caec3..109fc60 100644 --- a/js-src/14-gantt.js +++ b/js-src/14-gantt.js @@ -21,6 +21,7 @@ var mountTarget = null; var resizeObserver = null; var tasks = []; + var sortState = { key: null, direction: 'asc' }; // ── Build DOM ── var wrapper = sf.el('div', { className: 'sf-gantt-split' }); @@ -43,7 +44,6 @@ var chartHeader = sf.el('div', { className: 'sf-gantt-pane-header' }); chartHeader.appendChild(sf.el('h3', null, config.chartTitle || 'Timeline')); - // View mode selector var viewControls = sf.el('div', { className: 'sf-gantt-view-controls' }); var viewSelect = sf.el('select', { className: 'sf-gantt-view-select' }); var modes = [ @@ -59,9 +59,7 @@ viewSelect.appendChild(opt); }); viewSelect.addEventListener('change', function () { - if (ganttChart) { - ganttChart.change_view_mode(viewSelect.value); - } + if (ganttChart) ganttChart.change_view_mode(viewSelect.value); }); viewControls.appendChild(viewSelect); @@ -111,8 +109,7 @@ ctrl.refresh = function () { if (ganttChart && tasks.length > 0) { - var frappeTasks = tasksToFrappe(tasks); - ganttChart.refresh(frappeTasks); + ganttChart.refresh(tasksToFrappe(tasks)); } }; @@ -124,11 +121,9 @@ }; ctrl.highlightTask = function (taskId) { - // Grid highlight grid.querySelectorAll('.sf-gantt-row').forEach(function (row) { row.classList.toggle('selected', row.dataset.taskId === taskId); }); - // Bar highlight var svg = chartContainer.querySelector('svg'); if (svg) { svg.querySelectorAll('.bar-wrapper').forEach(function (bw) { @@ -153,8 +148,6 @@ return ctrl; - // ── Internal ── - function initSplit() { if (typeof Split !== 'function') return; if (splitInstance) { @@ -224,12 +217,16 @@ return taskList .filter(function (t) { return t.start && t.end; }) .map(function (t) { + var customClass = t.custom_class || ''; + if (t.pinned) { + customClass = customClass ? customClass + ' pinned' : 'pinned'; + } return { id: t.id, name: t.name || t.label || t.id, start: t.start, end: t.end, - custom_class: t.custom_class || '', + custom_class: customClass, dependencies: t.dependencies || '', }; }); @@ -273,32 +270,36 @@ function renderGrid(taskList) { while (grid.firstChild) grid.removeChild(grid.firstChild); var table = sf.el('table', { className: 'sf-gantt-table' }); - - // Header - var thead = sf.el('thead'); - var headerRow = sf.el('tr'); var columns = config.columns || [ { key: 'name', label: 'Task' }, { key: 'start', label: 'Start' }, { key: 'end', label: 'End' }, ]; + var sortedTasks = sortTasks(taskList); + + var thead = sf.el('thead'); + var headerRow = sf.el('tr'); columns.forEach(function (col) { - headerRow.appendChild(sf.el('th', null, col.label)); + headerRow.appendChild(buildHeaderCell(col)); }); thead.appendChild(headerRow); table.appendChild(thead); - // Body var tbody = sf.el('tbody'); - taskList.forEach(function (task) { + sortedTasks.forEach(function (task) { + var rowClasses = ['sf-gantt-row']; + if (task.custom_class) rowClasses.push(task.custom_class); + if (task.projectIndex != null) rowClasses.push('sf-project-' + task.projectIndex); + var tr = sf.el('tr', { - className: 'sf-gantt-row' + (task.custom_class ? ' ' + task.custom_class : ''), + className: rowClasses.join(' '), dataset: { taskId: task.id }, onClick: function () { ctrl.highlightTask(task.id); if (config.onTaskClick) config.onTaskClick(task); }, }); + columns.forEach(function (col) { var td = sf.el('td'); if (col.key === 'name') { @@ -316,12 +317,64 @@ } tr.appendChild(td); }); + tbody.appendChild(tr); }); table.appendChild(tbody); grid.appendChild(table); } + function buildHeaderCell(col) { + if (!col.sortable) { + return sf.el('th', null, col.label); + } + + var isCurrent = sortState.key === col.key; + var th = sf.el('th', { + className: 'sortable' + (isCurrent ? ' active' : ''), + role: 'button', + tabIndex: 0, + 'aria-sort': isCurrent ? (sortState.direction === 'asc' ? 'ascending' : 'descending') : 'none', + }); + th.appendChild(document.createTextNode(col.label)); + th.appendChild(sf.el('span', { className: 'sort-icon' }, isCurrent ? (sortState.direction === 'asc' ? '▲' : '▼') : '')); + + sf.bindActivation(th, function () { + if (sortState.key === col.key) { + sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc'; + } else { + sortState.key = col.key; + sortState.direction = 'asc'; + } + renderGrid(tasks); + }); + + return th; + } + + function sortTasks(taskList) { + if (!sortState.key) return taskList.slice(); + var sorted = taskList.slice(); + sorted.sort(function (a, b) { + var aVal = sortValue(a[sortState.key], sortState.key); + var bVal = sortValue(b[sortState.key], sortState.key); + if (aVal === bVal) return 0; + if (sortState.direction === 'asc') return aVal < bVal ? -1 : 1; + return aVal > bVal ? -1 : 1; + }); + return sorted; + } + + function sortValue(value, key) { + if (value == null) return ''; + if (key === 'start' || key === 'end') { + var parsed = Date.parse(value); + return isNaN(parsed) ? String(value).toLowerCase() : parsed; + } + if (typeof value === 'number') return value; + return String(value).toLowerCase(); + } + function defaultPopup(task) { var t = tasks.find(function (x) { return x.id === task.id; }); if (!t) return ''; diff --git a/static/sf/sf.css b/static/sf/sf.css index 57a4cd4..d87d351 100644 --- a/static/sf/sf.css +++ b/static/sf/sf.css @@ -1706,15 +1706,6 @@ body { border-radius: var(--sf-radius-sm); } -/* ── Maximized Pane ── */ -.sf-gantt-pane.maximized { - position: absolute !important; - inset: 0 !important; - z-index: 50; - height: 100% !important; - width: 100% !important; -} - /* ── Grid Table ── */ .sf-gantt-grid { height: 100%; @@ -1879,13 +1870,6 @@ body { .gantt .bar-wrapper.priority-2 .bar { opacity: 0.85; } .gantt .bar-wrapper.priority-3 .bar { opacity: 0.7; } -/* Cross-project dependency arrows */ -.gantt .arrow path.cross-project-arrow { - stroke: var(--sf-emerald-500); - stroke-dasharray: 5 3; - stroke-width: 1.4; -} - /* Bar hover glow */ .gantt .bar-wrapper:hover .bar { filter: brightness(1.12) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.22)); @@ -1917,48 +1901,6 @@ body { width 0.12s var(--sf-ease-out); } -/* Dragging state */ -.gantt .bar-wrapper.dragging { - opacity: 0.88; - filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.35)); - z-index: 100; - transition: none !important; -} - -.gantt .bar-wrapper.dragging .bar { - stroke-width: 2; - stroke: rgba(255, 255, 255, 0.7); -} - -body.sf-gantt-dragging { - cursor: grabbing !important; - user-select: none !important; -} - -body.sf-gantt-dragging * { - cursor: grabbing !important; -} - -/* ── Drag Tooltip ── */ -.sf-gantt-drag-tooltip { - position: fixed; - z-index: 9999; - background: rgba(15, 23, 42, 0.92); - color: #f1f5f9; - font-size: 11.5px; - font-family: var(--sf-font-mono); - font-weight: 500; - padding: 5px 10px; - border-radius: 6px; - pointer-events: none; - white-space: nowrap; - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); - backdrop-filter: blur(4px); - border: 1px solid rgba(255, 255, 255, 0.12); - letter-spacing: 0.02em; - display: none; -} - /* ── Pinned Task Indicators ── */ .gantt .bar-wrapper.pinned .bar { stroke: var(--sf-amber-500); @@ -1966,12 +1908,6 @@ body.sf-gantt-dragging * { stroke-dasharray: 6 3; } -.sf-gantt-pin-icon { - color: var(--sf-amber-500); - font-size: 11px; - opacity: 0.85; -} - /* ── Highlighted task (pulse from grid click) ── */ .gantt .bar-wrapper.highlighted .bar { animation: sf-gantt-pulse 0.5s ease-in-out 3; @@ -2008,40 +1944,6 @@ body.sf-gantt-dragging * { color: var(--sf-gray-500); } -/* ── Project Badge (grid table) ── */ -.sf-project-badge { - display: inline-flex; - align-items: center; - padding: var(--sf-badge-padding-y) var(--sf-badge-padding-x); - padding-left: 20px; - border-radius: var(--sf-badge-radius); - font-size: var(--sf-badge-font-size); - font-weight: var(--sf-badge-font-weight); - position: relative; - background: var(--sf-gray-100); -} - -.sf-project-badge::before { - content: ''; - position: absolute; - left: 7px; - top: 50%; - transform: translateY(-50%); - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; -} - -.sf-project-badge.sf-project-0 { color: var(--sf-project-0); background: var(--sf-project-0-light); } -.sf-project-badge.sf-project-1 { color: var(--sf-project-1); background: var(--sf-project-1-light); } -.sf-project-badge.sf-project-2 { color: var(--sf-project-2); background: var(--sf-project-2-light); } -.sf-project-badge.sf-project-3 { color: var(--sf-project-3); background: var(--sf-project-3-light); } -.sf-project-badge.sf-project-4 { color: var(--sf-project-4); background: var(--sf-project-4-light); } -.sf-project-badge.sf-project-5 { color: var(--sf-project-5); background: var(--sf-project-5-light); } -.sf-project-badge.sf-project-6 { color: var(--sf-project-6); background: var(--sf-project-6-light); } -.sf-project-badge.sf-project-7 { color: var(--sf-project-7); background: var(--sf-project-7-light); } - /* ── Gantt Popup ── */ .sf-gantt-popup { padding: 8px 12px; diff --git a/static/sf/sf.js b/static/sf/sf.js index 38df2f0..9b1196d 100644 --- a/static/sf/sf.js +++ b/static/sf/sf.js @@ -1380,6 +1380,7 @@ const SF = (function () { var mountTarget = null; var resizeObserver = null; var tasks = []; + var sortState = { key: null, direction: 'asc' }; // ── Build DOM ── var wrapper = sf.el('div', { className: 'sf-gantt-split' }); @@ -1402,7 +1403,6 @@ const SF = (function () { var chartHeader = sf.el('div', { className: 'sf-gantt-pane-header' }); chartHeader.appendChild(sf.el('h3', null, config.chartTitle || 'Timeline')); - // View mode selector var viewControls = sf.el('div', { className: 'sf-gantt-view-controls' }); var viewSelect = sf.el('select', { className: 'sf-gantt-view-select' }); var modes = [ @@ -1418,9 +1418,7 @@ const SF = (function () { viewSelect.appendChild(opt); }); viewSelect.addEventListener('change', function () { - if (ganttChart) { - ganttChart.change_view_mode(viewSelect.value); - } + if (ganttChart) ganttChart.change_view_mode(viewSelect.value); }); viewControls.appendChild(viewSelect); @@ -1470,8 +1468,7 @@ const SF = (function () { ctrl.refresh = function () { if (ganttChart && tasks.length > 0) { - var frappeTasks = tasksToFrappe(tasks); - ganttChart.refresh(frappeTasks); + ganttChart.refresh(tasksToFrappe(tasks)); } }; @@ -1483,11 +1480,9 @@ const SF = (function () { }; ctrl.highlightTask = function (taskId) { - // Grid highlight grid.querySelectorAll('.sf-gantt-row').forEach(function (row) { row.classList.toggle('selected', row.dataset.taskId === taskId); }); - // Bar highlight var svg = chartContainer.querySelector('svg'); if (svg) { svg.querySelectorAll('.bar-wrapper').forEach(function (bw) { @@ -1512,8 +1507,6 @@ const SF = (function () { return ctrl; - // ── Internal ── - function initSplit() { if (typeof Split !== 'function') return; if (splitInstance) { @@ -1583,12 +1576,16 @@ const SF = (function () { return taskList .filter(function (t) { return t.start && t.end; }) .map(function (t) { + var customClass = t.custom_class || ''; + if (t.pinned) { + customClass = customClass ? customClass + ' pinned' : 'pinned'; + } return { id: t.id, name: t.name || t.label || t.id, start: t.start, end: t.end, - custom_class: t.custom_class || '', + custom_class: customClass, dependencies: t.dependencies || '', }; }); @@ -1632,32 +1629,36 @@ const SF = (function () { function renderGrid(taskList) { while (grid.firstChild) grid.removeChild(grid.firstChild); var table = sf.el('table', { className: 'sf-gantt-table' }); - - // Header - var thead = sf.el('thead'); - var headerRow = sf.el('tr'); var columns = config.columns || [ { key: 'name', label: 'Task' }, { key: 'start', label: 'Start' }, { key: 'end', label: 'End' }, ]; + var sortedTasks = sortTasks(taskList); + + var thead = sf.el('thead'); + var headerRow = sf.el('tr'); columns.forEach(function (col) { - headerRow.appendChild(sf.el('th', null, col.label)); + headerRow.appendChild(buildHeaderCell(col)); }); thead.appendChild(headerRow); table.appendChild(thead); - // Body var tbody = sf.el('tbody'); - taskList.forEach(function (task) { + sortedTasks.forEach(function (task) { + var rowClasses = ['sf-gantt-row']; + if (task.custom_class) rowClasses.push(task.custom_class); + if (task.projectIndex != null) rowClasses.push('sf-project-' + task.projectIndex); + var tr = sf.el('tr', { - className: 'sf-gantt-row' + (task.custom_class ? ' ' + task.custom_class : ''), + className: rowClasses.join(' '), dataset: { taskId: task.id }, onClick: function () { ctrl.highlightTask(task.id); if (config.onTaskClick) config.onTaskClick(task); }, }); + columns.forEach(function (col) { var td = sf.el('td'); if (col.key === 'name') { @@ -1675,12 +1676,64 @@ const SF = (function () { } tr.appendChild(td); }); + tbody.appendChild(tr); }); table.appendChild(tbody); grid.appendChild(table); } + function buildHeaderCell(col) { + if (!col.sortable) { + return sf.el('th', null, col.label); + } + + var isCurrent = sortState.key === col.key; + var th = sf.el('th', { + className: 'sortable' + (isCurrent ? ' active' : ''), + role: 'button', + tabIndex: 0, + 'aria-sort': isCurrent ? (sortState.direction === 'asc' ? 'ascending' : 'descending') : 'none', + }); + th.appendChild(document.createTextNode(col.label)); + th.appendChild(sf.el('span', { className: 'sort-icon' }, isCurrent ? (sortState.direction === 'asc' ? '▲' : '▼') : '')); + + sf.bindActivation(th, function () { + if (sortState.key === col.key) { + sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc'; + } else { + sortState.key = col.key; + sortState.direction = 'asc'; + } + renderGrid(tasks); + }); + + return th; + } + + function sortTasks(taskList) { + if (!sortState.key) return taskList.slice(); + var sorted = taskList.slice(); + sorted.sort(function (a, b) { + var aVal = sortValue(a[sortState.key], sortState.key); + var bVal = sortValue(b[sortState.key], sortState.key); + if (aVal === bVal) return 0; + if (sortState.direction === 'asc') return aVal < bVal ? -1 : 1; + return aVal > bVal ? -1 : 1; + }); + return sorted; + } + + function sortValue(value, key) { + if (value == null) return ''; + if (key === 'start' || key === 'end') { + var parsed = Date.parse(value); + return isNaN(parsed) ? String(value).toLowerCase() : parsed; + } + if (typeof value === 'number') return value; + return String(value).toLowerCase(); + } + function defaultPopup(task) { var t = tasks.find(function (x) { return x.id === task.id; }); if (!t) return ''; diff --git a/tests/instance-local-dom.test.js b/tests/instance-local-dom.test.js index 95b0857..5abbb5f 100644 --- a/tests/instance-local-dom.test.js +++ b/tests/instance-local-dom.test.js @@ -263,3 +263,47 @@ test('gantt initSplit keeps accepting scalar splitMinSize values', () => { assert.equal(splitCalls[0].options.minSize[0], 160); assert.equal(splitCalls[0].options.minSize[1], 160); }); + +test('gantt sortable columns render and reorder grid rows without throwing', () => { + const { SF } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js']); + + const gantt = SF.gantt.create({ + columns: [ + { key: 'name', label: 'Task', sortable: true }, + { key: 'start', label: 'Start' }, + ], + }); + + gantt.setTasks([ + { id: 'b', name: 'Beta', start: '2026-03-22', end: '2026-03-23' }, + { id: 'a', name: 'Alpha', start: '2026-03-21', end: '2026-03-22' }, + ]); + + const header = gantt.el.querySelector('th'); + header.click(); + + const rows = gantt.el.querySelectorAll('.sf-gantt-row'); + assert.equal(rows[0].dataset.taskId, 'a'); + assert.equal(rows[1].dataset.taskId, 'b'); +}); + +test('gantt pinned tasks propagate pinned custom class to chart tasks', () => { + let seenTasks = null; + + const { SF } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], { + Gantt: function (_selector, tasks) { + seenTasks = tasks; + return { + change_view_mode() {}, + refresh() {}, + }; + }, + }); + + const gantt = SF.gantt.create({}); + gantt.setTasks([ + { id: 'task-1', start: '2026-03-21', end: '2026-03-22', pinned: true, custom_class: 'critical' }, + ]); + + assert.equal(seenTasks[0].custom_class, 'critical pinned'); +});