From b0fd767c804fc024df20e513603f7e3077350bd9 Mon Sep 17 00:00:00 2001 From: NPS Agent Date: Tue, 12 May 2026 12:31:44 +0930 Subject: [PATCH] Changed it so that tasks can be dragged and reordered, as well as having tasks breathe to leave a space where the task will go when you let go --- PROGRESS.md | 3 ++ app.jsx | 6 ++- backend/main.py | 11 +++-- backend/models.py | 4 +- backend/schemas.py | 2 + components.jsx | 4 +- dashy.db | Bin 81920 -> 81920 bytes screens.jsx | 112 +++++++++++++++++++++++++++++++++++---------- styles.css | 15 ++++++ 9 files changed, 127 insertions(+), 30 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 66539f1..5969834 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -63,6 +63,9 @@ 19. **Dynamic UI Integration:** Completely refactored the navigation and boards to build columns and tabs dynamically from the live database user list. 20. **Functional Search:** Implemented a real-time task search feature. Clicking the search icon now reveals an inline search bar that filters tasks by title, description, or tags across all views. 21. **Advanced Audit Filtering:** Upgraded the Audit Log for Admins with a search bar and a granular event type filter. Admins can now filter the history by specific actions (e.g., "deleted tasks", "moved tasks", "user management") and search through summaries in real-time. +22. **Persistent Task Reordering:** Implemented drag-and-drop reordering within and between columns. + - **Backend:** Added a `position` (Float) column to the `tasks` table and updated API endpoints to support position updates and sorted fetching. + - **UI:** Enhanced the Kanban board with a "drop-between-cards" detection logic and a visual blue drop indicator. Positions are persisted to the database instantly on drop. ### Phase 3: Advanced Features - **Real-time Notifications:** Explore WebSockets for task assignments. diff --git a/app.jsx b/app.jsx index d6799f0..f9b2292 100644 --- a/app.jsx +++ b/app.jsx @@ -169,9 +169,11 @@ function App() { } }; - const moveTask = async (taskId, toUserId) => { + const moveTask = async (taskId, toUserId, position = null) => { try { - await api.updateTask(taskId, { assignee_id: toUserId }); + const updates = { assignee_id: toUserId }; + if (position !== null) updates.position = position; + await api.updateTask(taskId, updates); await api.addAudit({ actor: meId, action: 'task_moved', summary: 'Moved task to ' + (merge(toUserId)||{}).name, target: taskId }); } catch(e) {} }; diff --git a/backend/main.py b/backend/main.py index d211daa..8f2cfcb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -99,17 +99,21 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user: model @app.get("/tasks", response_model=List[schemas.Task]) def read_tasks(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): - return db.query(models.Task).filter(models.Task.deleted_at == None).all() + return db.query(models.Task).filter(models.Task.deleted_at == None).order_by(models.Task.position.asc()).all() @app.get("/tasks/deleted", response_model=List[schemas.Task]) def read_deleted_tasks(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): if current_user.account_type != "admin": raise HTTPException(status_code=403, detail="Not enough permissions") - return db.query(models.Task).filter(models.Task.deleted_at != None).all() + return db.query(models.Task).filter(models.Task.deleted_at != None).order_by(models.Task.position.asc()).all() @app.post("/tasks", response_model=schemas.Task) def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): task_id = task.id or f"t_{uuid.uuid4().hex[:8]}" + + # Calculate position (max in column + 1000) + max_pos = db.query(func.max(models.Task.position)).filter(models.Task.assignee_id == task.assignee_id).scalar() or 0.0 + db_task = models.Task( id=task_id, title=task.title, @@ -120,7 +124,8 @@ def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db), current source=task.source, status=task.status, due_at=task.due_at, - reminder_at=task.reminder_at + reminder_at=task.reminder_at, + position=max_pos + 1000.0 ) db.add(db_task) diff --git a/backend/models.py b/backend/models.py index 9c83066..a6e1854 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, ForeignKey, Table, DateTime, Enum +from sqlalchemy import Column, Integer, String, ForeignKey, Table, DateTime, Enum, Float from sqlalchemy.orm import relationship from sqlalchemy.sql import func from .database import Base @@ -43,6 +43,7 @@ class Task(Base): due_at = Column(DateTime(timezone=True)) reminder_at = Column(DateTime(timezone=True)) deleted_at = Column(DateTime(timezone=True)) + position = Column(Float, default=0.0) assignee = relationship("User", back_populates="tasks", foreign_keys=[assignee_id]) tags = relationship("Tag", secondary=task_tags, back_populates="tasks") @@ -90,3 +91,4 @@ class Workspace(Base): id = Column(String, primary_key=True) name = Column(String, nullable=False) timezone = Column(String, nullable=False, default="Pacific/Auckland") + diff --git a/backend/schemas.py b/backend/schemas.py index 6f74bcc..db2058e 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -68,6 +68,7 @@ class TaskBase(BaseModel): due_at: Optional[datetime] = None reminder_at: Optional[datetime] = None deleted_at: Optional[datetime] = None + position: float = 0.0 class TaskCreate(TaskBase): id: Optional[str] = None @@ -81,6 +82,7 @@ class TaskUpdate(BaseModel): status: Optional[str] = None due_at: Optional[datetime] = None reminder_at: Optional[datetime] = None + position: Optional[float] = None class Task(TaskBase): id: str diff --git a/components.jsx b/components.jsx index d47d027..83eb74a 100644 --- a/components.jsx +++ b/components.jsx @@ -97,12 +97,13 @@ function findUser(id) { return USERS.find(u => u.id === id); } -function TaskCard({ task, onOpen, density = 'cozy', dragging = false, onDragStart, onDragEnd }) { +function TaskCard({ task, onOpen, density = 'cozy', dragging = false, onDragStart, onDragEnd, onDragOver }) { const author = findUser(task.addedBy); const isAuto = task.status === 'unsuccessful' || task.status === 'billing'; return (
onDragEnd && onDragEnd()} + onDragOver={onDragOver} onClick={() => onOpen && onOpen(task)} data-comment-anchor={"task-" + task.id} > diff --git a/dashy.db b/dashy.db index f0716934aac245edf8d036d0b1024d3aa744203c..eb3f74e67ff829ade336a53ff06e5789dd0ad21a 100644 GIT binary patch delta 3599 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 aww%t=F&d&9DNm*o>dG;4dwPS#oAwV&?7sy7 diff --git a/screens.jsx b/screens.jsx index 60700f2..9206ad0 100644 --- a/screens.jsx +++ b/screens.jsx @@ -212,29 +212,59 @@ function HeadsUp({ items, onDismiss, onOpenTask }) { function OverviewScreen({ tasks, onOpen, onAddFor, density, onMoveTask, dbUsers = [] }) { const byUser = Object.fromEntries(dbUsers.map(u => [u.id, []])); tasks.forEach(t => { if (byUser[t.assignee] && t.status !== 'closed') byUser[t.assignee].push(t); }); + const [draggingTask, setDraggingTask] = React.useState(null); const [dragOverCol, setDragOverCol] = React.useState(null); + const [dragOverTaskId, setDragOverTaskId] = React.useState(null); + const [dropSide, setDropSide] = React.useState('bottom'); // 'top' or 'bottom' + return (
{dbUsers.map(u => ( !draggingTask || t.id !== draggingTask.id)} onOpen={onOpen} onAdd={() => onAddFor(u.id)} density={density} dragOver={dragOverCol === u.id && draggingTask && draggingTask.assignee !== u.id} onDragOver={(uid) => setDragOverCol(uid)} - onDragLeave={() => setDragOverCol(prev => prev === u.id ? null : prev)} - onDragStartCard={(t) => setDraggingTask(t)} - onDragEndCard={() => { setDraggingTask(null); setDragOverCol(null); }} + onDragLeave={() => { setDragOverCol(null); setDragOverTaskId(null); }} + onDragStartCard={(t) => { + // Use a tiny timeout to ensure the drag image is captured before we hide the element + setTimeout(() => setDraggingTask(t), 0); + }} + onDragEndCard={() => { setDraggingTask(null); setDragOverCol(null); setDragOverTaskId(null); }} draggingId={draggingTask && draggingTask.id} + dragOverTaskId={dragOverTaskId} + dropSide={dropSide} + onDragOverTask={(tid, side) => { setDragOverTaskId(tid); setDropSide(side); }} onDropTask={(toId) => { - if (draggingTask && draggingTask.assignee !== toId) { - onMoveTask && onMoveTask(draggingTask.id, toId); + if (!draggingTask) return; + + // Calculate position using the list that DOES NOT include the dragging task + const colTasks = byUser[toId].filter(t => t.id !== draggingTask.id); + let newPos = 0; + + if (dragOverTaskId) { + const idx = colTasks.findIndex(t => t.id === dragOverTaskId); + if (dropSide === 'top') { + const prev = colTasks[idx - 1]; + newPos = prev ? (colTasks[idx].position + prev.position) / 2 : colTasks[idx].position / 2; + } else { + const next = colTasks[idx + 1]; + newPos = next ? (colTasks[idx].position + next.position) / 2 : colTasks[idx].position + 1000; + } + } else { + // Dropped on empty area or no specific task + const last = colTasks[colTasks.length - 1]; + newPos = last ? last.position + 1000 : 1000; } - setDraggingTask(null); setDragOverCol(null); + + onMoveTask && onMoveTask(draggingTask.id, toId, newPos); + setDraggingTask(null); setDragOverCol(null); setDragOverTaskId(null); }} /> ))} @@ -242,13 +272,17 @@ function OverviewScreen({ tasks, onOpen, onAddFor, density, onMoveTask, dbUsers ); } -function Column({ user, tasks, onOpen, onAdd, density, onDropTask, dragOver, onDragOver, onDragLeave, onDragStartCard, onDragEndCard, draggingId }) { +function Column({ user, tasks, onOpen, onAdd, density, onDropTask, dragOver, onDragOver, onDragLeave, onDragStartCard, onDragEndCard, draggingId, dragOverTaskId, dropSide, onDragOverTask }) { return (
{ e.preventDefault(); onDragOver && onDragOver(user.id); }} - onDragLeave={(e) => { onDragLeave && onDragLeave(user.id); }} + onDragLeave={(e) => { + if (e.relatedTarget && !e.currentTarget.contains(e.relatedTarget)) { + onDragLeave && onDragLeave(user.id); + } + }} onDrop={(e) => { e.preventDefault(); onDropTask && onDropTask(user.id); }} >
@@ -266,23 +300,55 @@ function Column({ user, tasks, onOpen, onAdd, density, onDropTask, dragOver, onD
-
- {tasks.length === 0 && ( + +
+ {tasks.length === 0 && !dragOver && (
— inbox zero —
)} - {tasks.map(t => ( - - ))} + + {tasks.length === 0 && dragOver && ( +
+ )} + + {tasks.map(t => { + const isOver = dragOverTaskId === t.id; + const isTop = isOver && dropSide === 'top'; + const isBottom = isOver && dropSide === 'bottom'; + + return ( +
+ {isOver && ( +
+ )} + { + const rect = e.currentTarget.getBoundingClientRect(); + const mid = rect.top + rect.height / 2; + onDragOverTask(t.id, e.clientY < mid ? 'top' : 'bottom'); + }} + /> +
+ ); + })}
); diff --git a/styles.css b/styles.css index d011f08..add780f 100644 --- a/styles.css +++ b/styles.css @@ -356,6 +356,21 @@ input, textarea { font: inherit; color: inherit; } padding: 18px 0; } +.drop-placeholder { + height: 52px; + background: color-mix(in oklch, var(--accent) 6%, var(--bg-sunken)); + border: 1.5px dashed var(--accent-line); + border-radius: var(--radius); + margin: 4px 0; + pointer-events: none; + animation: placeholder-pulse 1.5s ease-in-out infinite; +} + +@keyframes placeholder-pulse { + 0%, 100% { opacity: 0.5; border-color: var(--accent-line); } + 50% { opacity: 1; border-color: var(--accent); } +} + /* === CARD === */ .card { background: var(--bg-elev);