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:
@@ -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.
|
||||
|
||||
@@ -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) {}
|
||||
};
|
||||
|
||||
+8
-3
@@ -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)
|
||||
|
||||
|
||||
+3
-1
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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 (
|
||||
<article
|
||||
className={"card" + (dragging ? " card--drag" : "") + (isAuto ? " card--flagged" : "")}
|
||||
style={dragging ? { opacity: 0, pointerEvents: 'none' } : {}}
|
||||
data-density={density}
|
||||
data-priority={task.priority}
|
||||
draggable
|
||||
@@ -112,6 +113,7 @@ function TaskCard({ task, onOpen, density = 'cozy', dragging = false, onDragStar
|
||||
onDragStart && onDragStart(task);
|
||||
}}
|
||||
onDragEnd={() => onDragEnd && onDragEnd()}
|
||||
onDragOver={onDragOver}
|
||||
onClick={() => onOpen && onOpen(task)}
|
||||
data-comment-anchor={"task-" + task.id}
|
||||
>
|
||||
|
||||
+81
-15
@@ -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 (
|
||||
<div className="board">
|
||||
{dbUsers.map(u => (
|
||||
<Column
|
||||
key={u.id}
|
||||
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}
|
||||
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;
|
||||
}
|
||||
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 (
|
||||
<section
|
||||
className={"column" + (dragOver ? " column--over" : "")}
|
||||
className={"column" + (dragOver && tasks.length === 0 ? " column--over" : "")}
|
||||
data-comment-anchor={"col-" + 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); }}
|
||||
>
|
||||
<header className="column__head">
|
||||
@@ -266,23 +300,55 @@ function Column({ user, tasks, onOpen, onAdd, density, onDropTask, dragOver, onD
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
<span className="mono">— inbox zero —</span>
|
||||
</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
|
||||
key={t.id}
|
||||
task={t}
|
||||
onOpen={onOpen}
|
||||
density={density}
|
||||
dragging={draggingId === t.id}
|
||||
onDragStart={onDragStartCard}
|
||||
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>
|
||||
</section>
|
||||
);
|
||||
|
||||
+15
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user