UNPKG

@boundless-oss/atlas

Version:

Atlas - MCP Server for comprehensive startup project management

682 lines (578 loc) 18.5 kB
// Agile Board Drag and Drop Functionality class AgileBoard { constructor() { this.draggedElement = null; this.sourceColumn = null; this.setupDragAndDrop(); } setupDragAndDrop() { this.setupStoryCardDragHandlers(); this.setupColumnDropHandlers(); } setupStoryCardDragHandlers() { // We'll set up event listeners for dynamically created story cards document.addEventListener('dragstart', (e) => { if (e.target.classList.contains('story-card')) { this.handleDragStart(e); } }); document.addEventListener('dragend', (e) => { if (e.target.classList.contains('story-card')) { this.handleDragEnd(e); } }); } setupColumnDropHandlers() { const columns = document.querySelectorAll('.cards-container'); columns.forEach(column => { column.addEventListener('dragover', (e) => this.handleDragOver(e)); column.addEventListener('drop', (e) => this.handleDrop(e)); column.addEventListener('dragenter', (e) => this.handleDragEnter(e)); column.addEventListener('dragleave', (e) => this.handleDragLeave(e)); }); } handleDragStart(e) { this.draggedElement = e.target; this.sourceColumn = e.target.closest('.cards-container'); e.target.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/html', e.target.outerHTML); // Add visual feedback setTimeout(() => { e.target.style.opacity = '0.5'; }, 0); console.log('Drag started:', e.target.dataset.storyId); } handleDragEnd(e) { e.target.classList.remove('dragging'); e.target.style.opacity = '1'; // Remove drop zone highlights and restore empty states document.querySelectorAll('.cards-container').forEach(container => { container.classList.remove('drag-over'); // Show empty state if container has no cards const cards = container.querySelectorAll('.story-card'); if (cards.length === 0) { const emptyState = container.querySelector('.empty-state'); if (emptyState) { emptyState.style.display = ''; } } }); this.draggedElement = null; this.sourceColumn = null; } handleDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const container = e.currentTarget; const afterElement = this.getDragAfterElement(container, e.clientY); const dragging = document.querySelector('.dragging'); if (afterElement == null) { container.appendChild(dragging); } else { container.insertBefore(dragging, afterElement); } } handleDragEnter(e) { e.preventDefault(); const container = e.currentTarget; container.classList.add('drag-over'); // Hide empty state when dragging over const emptyState = container.querySelector('.empty-state'); if (emptyState) { emptyState.style.display = 'none'; } } handleDragLeave(e) { // Only remove highlight if we're actually leaving the container if (!e.currentTarget.contains(e.relatedTarget)) { const container = e.currentTarget; container.classList.remove('drag-over'); // Show empty state again if no cards in container const cards = container.querySelectorAll('.story-card'); if (cards.length === 0) { const emptyState = container.querySelector('.empty-state'); if (emptyState) { emptyState.style.display = ''; } } } } handleDrop(e) { e.preventDefault(); const targetColumn = e.currentTarget; targetColumn.classList.remove('drag-over'); if (!this.draggedElement || !this.sourceColumn) { return; } const storyId = this.draggedElement.dataset.storyId; const fromStatus = this.getColumnStatus(this.sourceColumn); const toStatus = this.getColumnStatus(targetColumn); if (fromStatus === toStatus) { return; // No actual move needed } // Calculate new index in target column const targetCards = Array.from(targetColumn.querySelectorAll('.story-card')); const newIndex = targetCards.indexOf(this.draggedElement); console.log('Story dropped:', { storyId, fromStatus, toStatus, newIndex }); // Send move request to server this.moveStory(storyId, fromStatus, toStatus, newIndex); // Update column counts this.updateColumnCounts(); // Show success feedback this.showMoveSuccess(storyId, toStatus); } getDragAfterElement(container, y) { const draggableElements = [...container.querySelectorAll('.story-card:not(.dragging)')]; return draggableElements.reduce((closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY }).element; } getColumnStatus(container) { const column = container.closest('.board-column'); return column.dataset.status; } async moveStory(storyId, fromStatus, toStatus, index) { try { const response = await fetch('/api/agile/story/move', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ storyId, fromStatus, toStatus, index }) }); if (!response.ok) { throw new Error('Failed to move story'); } const result = await response.json(); console.log('Story moved successfully:', result); // Emit real-time update if WebSocket is available if (window.dashboard && window.dashboard.socket) { window.dashboard.socket.emit('move_story', { storyId, fromColumn: fromStatus, toColumn: toStatus, index }); } } catch (error) { console.error('Error moving story:', error); this.showMoveError(storyId); // Revert the move on error this.revertStoryMove(storyId, fromStatus); } } revertStoryMove(storyId, originalStatus) { const storyCard = document.querySelector(`[data-story-id="${storyId}"]`); const originalContainer = document.querySelector( `.board-column[data-status="${originalStatus}"] .cards-container` ); if (storyCard && originalContainer) { originalContainer.appendChild(storyCard); this.updateColumnCounts(); } } updateColumnCounts() { const columns = document.querySelectorAll('.board-column'); columns.forEach(column => { const cardsContainer = column.querySelector('.cards-container'); const countElement = column.querySelector('.story-count'); const storyCards = cardsContainer.querySelectorAll('.story-card'); countElement.textContent = storyCards.length; }); } showMoveSuccess(storyId, toStatus) { // Create a temporary success indicator const indicator = document.createElement('div'); indicator.className = 'move-success'; indicator.innerHTML = ` <i class="fas fa-check-circle"></i> Story moved to ${toStatus.replace('-', ' ')} `; indicator.style.cssText = ` position: fixed; top: 20px; right: 20px; background: #10b981; color: white; padding: 12px 16px; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 1000; animation: slideInRight 0.3s ease; `; document.body.appendChild(indicator); setTimeout(() => { indicator.style.animation = 'slideOutRight 0.3s ease'; setTimeout(() => { document.body.removeChild(indicator); }, 300); }, 2000); } showMoveError(storyId) { const indicator = document.createElement('div'); indicator.className = 'move-error'; indicator.innerHTML = ` <i class="fas fa-exclamation-circle"></i> Failed to move story `; indicator.style.cssText = ` position: fixed; top: 20px; right: 20px; background: #ef4444; color: white; padding: 12px 16px; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 1000; animation: slideInRight 0.3s ease; `; document.body.appendChild(indicator); setTimeout(() => { indicator.style.animation = 'slideOutRight 0.3s ease'; setTimeout(() => { document.body.removeChild(indicator); }, 300); }, 3000); } // Story card interaction methods setupStoryCardInteractions() { document.addEventListener('click', (e) => { if (e.target.closest('.story-card')) { this.handleStoryCardClick(e.target.closest('.story-card')); } }); document.addEventListener('dblclick', (e) => { if (e.target.closest('.story-card')) { this.handleStoryCardDoubleClick(e.target.closest('.story-card')); } }); } handleStoryCardClick(storyCard) { // Remove previous selections document.querySelectorAll('.story-card.selected').forEach(card => { card.classList.remove('selected'); }); // Select clicked card storyCard.classList.add('selected'); // Show story details in a sidebar or modal this.showStoryDetails(storyCard.dataset.storyId); } handleStoryCardDoubleClick(storyCard) { // Open story editor this.editStory(storyCard.dataset.storyId); } showStoryDetails(storyId) { // Open the story modal via the dashboard if (window.dashboard && window.dashboard.openStoryModal) { window.dashboard.openStoryModal(storyId); } else { console.warn('Dashboard not available, cannot open story modal'); } } editStory(storyId) { // Use the dashboard's unified modal system if (window.dashboard && window.dashboard.openStoryModal) { // Open the modal first window.dashboard.openStoryModal(storyId); // Then trigger edit mode after a short delay to ensure modal is rendered setTimeout(() => { if (window.dashboard.enterEditMode) { window.dashboard.enterEditMode(); } }, 100); } else { console.error('Dashboard not available, cannot edit story'); } } async updateStoryTitle(storyId, newTitle) { try { const response = await fetch(`/api/agile/story/${storyId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: newTitle }) }); if (!response.ok) { throw new Error('Failed to update story'); } // Update the UI const storyCard = document.querySelector(`[data-story-id="${storyId}"]`); if (storyCard) { const titleElement = storyCard.querySelector('.story-title'); titleElement.textContent = newTitle; } // Emit real-time update if (window.dashboard && window.dashboard.socket) { window.dashboard.socket.emit('update_story', { storyId, updates: { title: newTitle } }); } } catch (error) { console.error('Error updating story:', error); alert('Failed to update story title'); } } // Keyboard shortcuts setupKeyboardShortcuts() { document.addEventListener('keydown', (e) => { const selectedCard = document.querySelector('.story-card.selected'); if (!selectedCard) return; const storyId = selectedCard.dataset.storyId; const currentColumn = selectedCard.closest('.board-column'); const currentStatus = currentColumn.dataset.status; switch (e.key) { case 'ArrowLeft': e.preventDefault(); this.moveStoryLeft(storyId, currentStatus); break; case 'ArrowRight': e.preventDefault(); this.moveStoryRight(storyId, currentStatus); break; case 'Delete': case 'Backspace': if (e.ctrlKey || e.metaKey) { e.preventDefault(); this.deleteStory(storyId); } break; case 'Enter': e.preventDefault(); this.editStory(storyId); break; case 'Escape': e.preventDefault(); selectedCard.classList.remove('selected'); break; } }); } moveStoryLeft(storyId, currentStatus) { const statusOrder = ['done', 'review', 'in-progress', 'todo']; const currentIndex = statusOrder.indexOf(currentStatus); if (currentIndex > 0) { const newStatus = statusOrder[currentIndex - 1]; this.moveStoryToColumn(storyId, currentStatus, newStatus); } } moveStoryRight(storyId, currentStatus) { const statusOrder = ['todo', 'in-progress', 'review', 'done']; const currentIndex = statusOrder.indexOf(currentStatus); if (currentIndex < statusOrder.length - 1) { const newStatus = statusOrder[currentIndex + 1]; this.moveStoryToColumn(storyId, currentStatus, newStatus); } } moveStoryToColumn(storyId, fromStatus, toStatus) { const storyCard = document.querySelector(`[data-story-id="${storyId}"]`); const targetContainer = document.querySelector( `.board-column[data-status="${toStatus}"] .cards-container` ); if (storyCard && targetContainer) { targetContainer.appendChild(storyCard); this.moveStory(storyId, fromStatus, toStatus, 0); this.updateColumnCounts(); } } deleteStory(storyId) { if (confirm('Are you sure you want to delete this story?')) { // This would call the delete API console.log('Delete story:', storyId); const storyCard = document.querySelector(`[data-story-id="${storyId}"]`); if (storyCard) { storyCard.remove(); this.updateColumnCounts(); } } } // Context menu setupContextMenu() { document.addEventListener('contextmenu', (e) => { if (e.target.closest('.story-card')) { e.preventDefault(); this.showContextMenu(e, e.target.closest('.story-card')); } }); // Hide context menu on click elsewhere document.addEventListener('click', () => { const existingMenu = document.querySelector('.context-menu'); if (existingMenu) { existingMenu.remove(); } }); } showContextMenu(e, storyCard) { const storyId = storyCard.dataset.storyId; const menu = document.createElement('div'); menu.className = 'context-menu'; menu.style.cssText = ` position: fixed; top: ${e.clientY}px; left: ${e.clientX}px; background: white; border: 1px solid #e2e8f0; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 1000; min-width: 150px; `; menu.innerHTML = ` <div class="context-menu-item" onclick="agileBoard.editStory('${storyId}')"> <i class="fas fa-edit"></i> Edit Story </div> <div class="context-menu-item" onclick="agileBoard.showStoryDetails('${storyId}')"> <i class="fas fa-info-circle"></i> View Details </div> <div class="context-menu-divider"></div> <div class="context-menu-item danger" onclick="agileBoard.deleteStory('${storyId}')"> <i class="fas fa-trash"></i> Delete Story </div> `; document.body.appendChild(menu); } // Initialize all functionality init() { this.setupDragAndDrop(); this.setupStoryCardInteractions(); this.setupKeyboardShortcuts(); this.setupContextMenu(); console.log('Agile Board initialized'); } } // Add CSS animations for notifications const style = document.createElement('style'); style.textContent = ` @keyframes slideInRight { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideOutRight { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } .cards-container.drag-over { background-color: rgba(59, 130, 246, 0.1); border: 2px dashed #3b82f6; } .story-card.dragging { transform: rotate(5deg); box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); } .story-card.selected { border: 2px solid #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); } .context-menu-item { padding: 8px 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 14px; } .context-menu-item:hover { background-color: #f8fafc; } .context-menu-item.danger { color: #ef4444; } .context-menu-item.danger:hover { background-color: #fef2f2; } .context-menu-divider { height: 1px; background-color: #e2e8f0; margin: 4px 0; } /* Story modal edit mode styles */ .modal.editing .title-edit { font-size: 1.25rem; font-weight: 500; padding: 4px 8px; border: 1px solid #d1d5db; border-radius: 4px; width: 100%; max-width: 400px; } .modal.editing .description-edit textarea { width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 4px; font-family: inherit; resize: vertical; } .story-detail-actions .btn-icon { margin-left: 8px; padding: 8px; border: none; background: #f8fafc; border-radius: 4px; cursor: pointer; transition: background-color 0.2s; } .story-detail-actions .btn-icon:hover { background: #e2e8f0; } .story-detail-actions .story-save-btn { background: #10b981; color: white; } .story-detail-actions .story-save-btn:hover { background: #059669; } .story-detail-actions .story-cancel-btn { background: #6b7280; color: white; } .story-detail-actions .story-cancel-btn:hover { background: #4b5563; } `; document.head.appendChild(style); // Initialize agile board when DOM is loaded document.addEventListener('DOMContentLoaded', () => { window.agileBoard = new AgileBoard(); window.agileBoard.init(); }); // Export for use in dashboard window.setupDragAndDrop = function() { if (window.agileBoard) { window.agileBoard.setupDragAndDrop(); } }; window.AgileBoard = AgileBoard;