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.
|
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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
+81
-15
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user