From 1fafae9705eb2401eaa9cee71df35c59d3dd7f40 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 1 Mar 2026 22:25:26 -0500 Subject: [PATCH] feat(web): add drag-and-drop between Kanban columns via SortableJS Wire up SortableJS in app.js to enable dragging task cards between Kanban columns. On drop, fires htmx PUT requests to the appropriate Board handler (Start/Pause/Resume/Complete) based on the column transition. Invalid transitions are reverted. Sortable instances are destroyed and recreated after htmx swaps to prevent memory leaks. Also centralizes detail panel open/close/edit helpers and Escape key handling. Co-Authored-By: Claude Opus 4.6 --- TaskTracker.Api/wwwroot/js/app.js | 159 +++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 2 deletions(-) diff --git a/TaskTracker.Api/wwwroot/js/app.js b/TaskTracker.Api/wwwroot/js/app.js index 86b651a..6862bdf 100644 --- a/TaskTracker.Api/wwwroot/js/app.js +++ b/TaskTracker.Api/wwwroot/js/app.js @@ -1,2 +1,157 @@ -// TaskTracker app.js — command palette, keyboard shortcuts, drag-and-drop wiring -// Will be populated in later tasks +// TaskTracker app.js — drag-and-drop, detail panel, keyboard shortcuts + +(function() { + 'use strict'; + + // ======================================== + // Drag-and-Drop (SortableJS) + // ======================================== + + function initKanban() { + document.querySelectorAll('.kanban-column-body').forEach(function(el) { + // Destroy existing Sortable instance if re-initializing + if (el._sortable) { + el._sortable.destroy(); + } + + el._sortable = new Sortable(el, { + group: 'kanban', + animation: 150, + ghostClass: 'task-card--ghost', + dragClass: 'task-card--dragging', + handle: '.task-card', // Only drag by the card itself + filter: '.create-task-form', // Don't drag the create form + onEnd: function(evt) { + var taskId = evt.item.dataset.taskId; + var fromStatus = evt.from.dataset.status; + var toStatus = evt.to.dataset.status; + + // No-op if dropped in same column + if (fromStatus === toStatus) return; + + // Determine the handler based on transition + var handler = null; + if (toStatus === 'Active' && fromStatus === 'Paused') { + handler = 'Resume'; + } else if (toStatus === 'Active') { + handler = 'Start'; + } else if (toStatus === 'Paused' && fromStatus === 'Active') { + handler = 'Pause'; + } else if (toStatus === 'Completed') { + handler = 'Complete'; + } else { + // Invalid transition — revert by moving the item back + evt.from.appendChild(evt.item); + return; + } + + // Fire htmx request to update the task status + htmx.ajax('PUT', '/board?handler=' + handler + '&id=' + taskId, { + target: '#kanban-board', + swap: 'innerHTML' + }); + } + }); + }); + } + + // ======================================== + // Detail Panel helpers + // ======================================== + + // Defined on window so inline onclick handlers and partial scripts can call them. + // The _TaskDetail partial also defines these inline; whichever loads last wins, + // but the behavior is identical. + + window.closeDetailPanel = function() { + var overlay = document.querySelector('.detail-overlay'); + var panel = document.querySelector('.detail-panel'); + if (overlay) overlay.classList.remove('detail-overlay--open'); + if (panel) panel.classList.remove('detail-panel--open'); + // Clear content after CSS transition completes + setTimeout(function() { + var container = document.getElementById('detail-panel'); + if (container && !document.querySelector('.detail-panel--open')) { + container.innerHTML = ''; + } + }, 300); + }; + + window.startEdit = function(fieldId) { + var container = document.getElementById('edit-' + fieldId); + if (!container) return; + var display = container.querySelector('.inline-edit-display'); + var form = container.querySelector('.inline-edit-form'); + if (display) display.style.display = 'none'; + if (form) { + form.style.display = ''; + var input = form.querySelector('input, textarea'); + if (input) { + input.focus(); + if (input.type === 'text' || input.tagName === 'TEXTAREA') { + input.setSelectionRange(input.value.length, input.value.length); + } + } + } + }; + + window.cancelEdit = function(fieldId) { + var container = document.getElementById('edit-' + fieldId); + if (!container) return; + var display = container.querySelector('.inline-edit-display'); + var form = container.querySelector('.inline-edit-form'); + if (display) display.style.display = ''; + if (form) form.style.display = 'none'; + }; + + // ======================================== + // Initialization + // ======================================== + + // Initialize Kanban drag-and-drop on page load + document.addEventListener('DOMContentLoaded', function() { + initKanban(); + }); + + // Re-initialize after htmx swaps that affect the kanban board. + // This is critical because htmx replaces DOM nodes, destroying Sortable instances. + document.addEventListener('htmx:afterSwap', function(evt) { + var target = evt.detail.target; + if (target && (target.id === 'kanban-board' || target.id === 'board-content' || (target.closest && target.closest('#kanban-board')))) { + initKanban(); + } + }); + + // Also handle htmx:afterSettle for cases where the DOM isn't fully ready on afterSwap + document.addEventListener('htmx:afterSettle', function(evt) { + var target = evt.detail.target; + if (target && (target.id === 'kanban-board' || target.id === 'board-content')) { + initKanban(); + } + }); + + // Open detail panel when its content is loaded via htmx + document.addEventListener('htmx:afterSwap', function(evt) { + if (evt.detail.target && evt.detail.target.id === 'detail-panel') { + // Panel content was just loaded — trigger open animation + requestAnimationFrame(function() { + var overlay = document.querySelector('.detail-overlay'); + var panel = document.querySelector('.detail-panel'); + if (overlay) overlay.classList.add('detail-overlay--open'); + if (panel) panel.classList.add('detail-panel--open'); + }); + } + }); + + // Global Escape key handler for closing the detail panel + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + var panel = document.querySelector('.detail-panel--open'); + if (panel) { + closeDetailPanel(); + e.preventDefault(); + } + } + }); + +})();