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

This commit is contained in:
NPS Agent
2026-05-12 12:31:44 +09:30
parent 84592b8b3b
commit b0fd767c80
9 changed files with 127 additions and 30 deletions
+3
View File
@@ -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. 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. 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. 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 ### Phase 3: Advanced Features
- **Real-time Notifications:** Explore WebSockets for task assignments. - **Real-time Notifications:** Explore WebSockets for task assignments.
+4 -2
View File
@@ -169,9 +169,11 @@ function App() {
} }
}; };
const moveTask = async (taskId, toUserId) => { const moveTask = async (taskId, toUserId, position = null) => {
try { 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 }); await api.addAudit({ actor: meId, action: 'task_moved', summary: 'Moved task to ' + (merge(toUserId)||{}).name, target: taskId });
} catch(e) {} } catch(e) {}
}; };
+8 -3
View File
@@ -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]) @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)): 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]) @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)): def read_deleted_tasks(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)):
if current_user.account_type != "admin": if current_user.account_type != "admin":
raise HTTPException(status_code=403, detail="Not enough permissions") 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) @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)): 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]}" 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( db_task = models.Task(
id=task_id, id=task_id,
title=task.title, title=task.title,
@@ -120,7 +124,8 @@ def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db), current
source=task.source, source=task.source,
status=task.status, status=task.status,
due_at=task.due_at, due_at=task.due_at,
reminder_at=task.reminder_at reminder_at=task.reminder_at,
position=max_pos + 1000.0
) )
db.add(db_task) db.add(db_task)
+3 -1
View File
@@ -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.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from .database import Base from .database import Base
@@ -43,6 +43,7 @@ class Task(Base):
due_at = Column(DateTime(timezone=True)) due_at = Column(DateTime(timezone=True))
reminder_at = Column(DateTime(timezone=True)) reminder_at = Column(DateTime(timezone=True))
deleted_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]) assignee = relationship("User", back_populates="tasks", foreign_keys=[assignee_id])
tags = relationship("Tag", secondary=task_tags, back_populates="tasks") tags = relationship("Tag", secondary=task_tags, back_populates="tasks")
@@ -90,3 +91,4 @@ class Workspace(Base):
id = Column(String, primary_key=True) id = Column(String, primary_key=True)
name = Column(String, nullable=False) name = Column(String, nullable=False)
timezone = Column(String, nullable=False, default="Pacific/Auckland") timezone = Column(String, nullable=False, default="Pacific/Auckland")
+2
View File
@@ -68,6 +68,7 @@ class TaskBase(BaseModel):
due_at: Optional[datetime] = None due_at: Optional[datetime] = None
reminder_at: Optional[datetime] = None reminder_at: Optional[datetime] = None
deleted_at: Optional[datetime] = None deleted_at: Optional[datetime] = None
position: float = 0.0
class TaskCreate(TaskBase): class TaskCreate(TaskBase):
id: Optional[str] = None id: Optional[str] = None
@@ -81,6 +82,7 @@ class TaskUpdate(BaseModel):
status: Optional[str] = None status: Optional[str] = None
due_at: Optional[datetime] = None due_at: Optional[datetime] = None
reminder_at: Optional[datetime] = None reminder_at: Optional[datetime] = None
position: Optional[float] = None
class Task(TaskBase): class Task(TaskBase):
id: str id: str
+3 -1
View File
@@ -97,12 +97,13 @@ function findUser(id) {
return USERS.find(u => u.id === 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 author = findUser(task.addedBy);
const isAuto = task.status === 'unsuccessful' || task.status === 'billing'; const isAuto = task.status === 'unsuccessful' || task.status === 'billing';
return ( return (
<article <article
className={"card" + (dragging ? " card--drag" : "") + (isAuto ? " card--flagged" : "")} className={"card" + (dragging ? " card--drag" : "") + (isAuto ? " card--flagged" : "")}
style={dragging ? { opacity: 0, pointerEvents: 'none' } : {}}
data-density={density} data-density={density}
data-priority={task.priority} data-priority={task.priority}
draggable draggable
@@ -112,6 +113,7 @@ function TaskCard({ task, onOpen, density = 'cozy', dragging = false, onDragStar
onDragStart && onDragStart(task); onDragStart && onDragStart(task);
}} }}
onDragEnd={() => onDragEnd && onDragEnd()} onDragEnd={() => onDragEnd && onDragEnd()}
onDragOver={onDragOver}
onClick={() => onOpen && onOpen(task)} onClick={() => onOpen && onOpen(task)}
data-comment-anchor={"task-" + task.id} data-comment-anchor={"task-" + task.id}
> >
BIN
View File
Binary file not shown.
+81 -15
View File
@@ -212,29 +212,59 @@ function HeadsUp({ items, onDismiss, onOpenTask }) {
function OverviewScreen({ tasks, onOpen, onAddFor, density, onMoveTask, dbUsers = [] }) { function OverviewScreen({ tasks, onOpen, onAddFor, density, onMoveTask, dbUsers = [] }) {
const byUser = Object.fromEntries(dbUsers.map(u => [u.id, []])); const byUser = Object.fromEntries(dbUsers.map(u => [u.id, []]));
tasks.forEach(t => { if (byUser[t.assignee] && t.status !== 'closed') byUser[t.assignee].push(t); }); tasks.forEach(t => { if (byUser[t.assignee] && t.status !== 'closed') byUser[t.assignee].push(t); });
const [draggingTask, setDraggingTask] = React.useState(null); const [draggingTask, setDraggingTask] = React.useState(null);
const [dragOverCol, setDragOverCol] = 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 ( return (
<div className="board"> <div className="board">
{dbUsers.map(u => ( {dbUsers.map(u => (
<Column <Column
key={u.id} key={u.id}
user={u} user={u}
tasks={byUser[u.id]} // Filter out the dragging task from its current column to make it "disappear" from origin
tasks={byUser[u.id].filter(t => !draggingTask || t.id !== draggingTask.id)}
onOpen={onOpen} onOpen={onOpen}
onAdd={() => onAddFor(u.id)} onAdd={() => onAddFor(u.id)}
density={density} density={density}
dragOver={dragOverCol === u.id && draggingTask && draggingTask.assignee !== u.id} dragOver={dragOverCol === u.id && draggingTask && draggingTask.assignee !== u.id}
onDragOver={(uid) => setDragOverCol(uid)} onDragOver={(uid) => setDragOverCol(uid)}
onDragLeave={() => setDragOverCol(prev => prev === u.id ? null : prev)} onDragLeave={() => { setDragOverCol(null); setDragOverTaskId(null); }}
onDragStartCard={(t) => setDraggingTask(t)} onDragStartCard={(t) => {
onDragEndCard={() => { setDraggingTask(null); setDragOverCol(null); }} // 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} draggingId={draggingTask && draggingTask.id}
dragOverTaskId={dragOverTaskId}
dropSide={dropSide}
onDragOverTask={(tid, side) => { setDragOverTaskId(tid); setDropSide(side); }}
onDropTask={(toId) => { onDropTask={(toId) => {
if (draggingTask && draggingTask.assignee !== toId) { if (!draggingTask) return;
onMoveTask && onMoveTask(draggingTask.id, toId);
// 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;
} }
setDraggingTask(null); setDragOverCol(null); } else {
// Dropped on empty area or no specific task
const last = colTasks[colTasks.length - 1];
newPos = last ? last.position + 1000 : 1000;
}
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 ( return (
<section <section
className={"column" + (dragOver ? " column--over" : "")} className={"column" + (dragOver && tasks.length === 0 ? " column--over" : "")}
data-comment-anchor={"col-" + user.id} data-comment-anchor={"col-" + user.id}
onDragOver={(e) => { e.preventDefault(); onDragOver && onDragOver(user.id); }} onDragOver={(e) => { 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); }} onDrop={(e) => { e.preventDefault(); onDropTask && onDropTask(user.id); }}
> >
<header className="column__head"> <header className="column__head">
@@ -266,23 +300,55 @@ function Column({ user, tasks, onOpen, onAdd, density, onDropTask, dragOver, onD
</button> </button>
</div> </div>
</header> </header>
<div className="column__list">
{tasks.length === 0 && ( <div className="column__list" style={{ flex: 1, position: 'relative' }}>
{tasks.length === 0 && !dragOver && (
<div className="column__empty"> <div className="column__empty">
<span className="mono"> inbox zero </span> <span className="mono"> inbox zero </span>
</div> </div>
)} )}
{tasks.map(t => (
{tasks.length === 0 && dragOver && (
<div className="drop-placeholder" />
)}
{tasks.map(t => {
const isOver = dragOverTaskId === t.id;
const isTop = isOver && dropSide === 'top';
const isBottom = isOver && dropSide === 'bottom';
return (
<div key={t.id} style={{
position: 'relative',
transition: 'padding 0.18s cubic-bezier(0.2, 0.8, 0.2, 1)',
paddingTop: isTop ? '60px' : '0px',
paddingBottom: isBottom ? '60px' : '0px'
}}>
{isOver && (
<div className="drop-placeholder" style={{
position: 'absolute',
left: 0, right: 0,
top: isTop ? '4px' : 'auto',
bottom: isBottom ? '4px' : 'auto',
margin: 0
}} />
)}
<TaskCard <TaskCard
key={t.id}
task={t} task={t}
onOpen={onOpen} onOpen={onOpen}
density={density} density={density}
dragging={draggingId === t.id} dragging={draggingId === t.id}
onDragStart={onDragStartCard} onDragStart={onDragStartCard}
onDragEnd={onDragEndCard} onDragEnd={onDragEndCard}
onDragOver={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
onDragOverTask(t.id, e.clientY < mid ? 'top' : 'bottom');
}}
/> />
))} </div>
);
})}
</div> </div>
</section> </section>
); );
+15
View File
@@ -356,6 +356,21 @@ input, textarea { font: inherit; color: inherit; }
padding: 18px 0; 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 === */
.card { .card {
background: var(--bg-elev); background: var(--bg-elev);