@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
682 lines (578 loc) • 18.5 kB
JavaScript
// 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;