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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,2 +1,157 @@
|
|||||||
// TaskTracker app.js — command palette, keyboard shortcuts, drag-and-drop wiring
|
// TaskTracker app.js — drag-and-drop, detail panel, keyboard shortcuts
|
||||||
// Will be populated in later tasks
|
|
||||||
|
(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user