Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions nerve/db/task_statuses.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,34 @@ async def list_task_statuses(self) -> list[dict]:
) as cursor:
return [dict(row) async for row in cursor]

async def reorder_task_statuses(self, names: list[str]) -> list[dict]:
"""Rewrite ``sort_order`` to match the given sequence of names.

Takes the whole desired order rather than one status's new index:
board columns are reordered by dragging, which shifts several
statuses at once, and sending the full sequence makes the write
idempotent and free of the intermediate states a per-row PATCH
storm would leave behind if one of them failed.

Names not present in the list keep their relative order after the
ones that are, so an unknown or omitted status can't be silently
dropped from the board.
"""
async with self._atomic():
async with self.db.execute(
"SELECT name FROM task_statuses ORDER BY sort_order ASC, name ASC"
) as cursor:
existing = [row[0] async for row in cursor]

wanted = [n for n in names if n in existing]
ordered = wanted + [n for n in existing if n not in wanted]
await self.db.executemany(
"UPDATE task_statuses SET sort_order = ? WHERE name = ?",
[(i, name) for i, name in enumerate(ordered)],
)
Comment thread
alex-clickhouse marked this conversation as resolved.

return await self.list_task_statuses()

async def get_task_status_def(self, name: str) -> dict | None:
async with self.db.execute(
"SELECT * FROM task_statuses WHERE name = ?", (name,)
Expand Down
18 changes: 18 additions & 0 deletions nerve/gateway/routes/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,24 @@ async def create_task_status(
return created


class TaskStatusReorderRequest(BaseModel):
"""The full desired column order, outermost first."""

names: list[str]


@router.post("/api/task-statuses/reorder")
async def reorder_task_statuses(
req: TaskStatusReorderRequest, user: dict = Depends(require_auth),
):
"""Set board column order in one write.

Declared above /{name} so "reorder" isn't captured as a status name.
"""
deps = get_deps()
return {"statuses": await deps.db.reorder_task_statuses(req.names)}


@router.patch("/api/task-statuses/{name}")
async def update_task_status(
name: str, req: TaskStatusUpdateRequest, user: dict = Depends(require_auth),
Expand Down
31 changes: 31 additions & 0 deletions tests/test_task_board_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,37 @@ async def test_patch_on_a_missing_task_is_404(self, setup):
resp = setup.client.patch("/api/tasks/nope", json={"status": "pending"})
assert resp.status_code == 404

# ── Column order ─────────────────────────────────────────────────────

async def test_reorder_sets_board_column_order(self, setup):
before = [l["status"] for l in setup.client.get("/api/tasks/board").json()["lanes"]]
target = list(reversed(before))

resp = setup.client.post("/api/task-statuses/reorder", json={"names": target})

assert resp.status_code == 200
assert [s["name"] for s in resp.json()["statuses"]] == target
# The board is what actually has to change.
after = [l["status"] for l in setup.client.get("/api/tasks/board").json()["lanes"]]
assert after == target

async def test_reorder_route_is_not_captured_as_a_status_name(self, setup):
"""/reorder must stay declared above /task-statuses/{name}."""
resp = setup.client.post("/api/task-statuses/reorder", json={"names": []})
assert resp.status_code == 200
assert "statuses" in resp.json()

async def test_reorder_keeps_omitted_statuses(self, setup):
before = [l["status"] for l in setup.client.get("/api/tasks/board").json()["lanes"]]

resp = setup.client.post(
"/api/task-statuses/reorder", json={"names": [before[-1]]},
)

names = [s["name"] for s in resp.json()["statuses"]]
assert names[0] == before[-1]
assert set(names) == set(before), "a status left out of the request vanished"

# ── List ─────────────────────────────────────────────────────────────

async def test_list_accepts_a_tag_filter(self, setup):
Expand Down
50 changes: 50 additions & 0 deletions tests/test_task_statuses.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,53 @@ async def test_status_list_handler(self, db: Database, tmp_path):
ctx = self._ctx(db, tmp_path)
text = _text(await task_status_list_handler(ctx, {}))
assert "pending" in text and "[protected]" in text


@pytest.mark.asyncio
class TestReorderTaskStatuses:
"""Board column order, rewritten as a whole sequence.

Reordering by drag shifts several statuses at once, so the API takes the
full desired order rather than one status's new index — that makes the
write idempotent and avoids the half-applied states a per-row PATCH
storm would leave if one of them failed.
"""

async def test_reorder_sets_the_given_sequence(self, db):
await db.create_task_status(name="review", label="Review", color="#111111")
original = [s["name"] for s in await db.list_task_statuses()]
reversed_order = list(reversed(original))

returned = await db.reorder_task_statuses(reversed_order)

assert [s["name"] for s in returned] == reversed_order
# And it persisted, rather than only shaping the return value.
assert [s["name"] for s in await db.list_task_statuses()] == reversed_order

async def test_reorder_is_idempotent(self, db):
order = [s["name"] for s in await db.list_task_statuses()]
await db.reorder_task_statuses(order)
await db.reorder_task_statuses(order)
assert [s["name"] for s in await db.list_task_statuses()] == order

async def test_omitted_statuses_are_kept_after_the_named_ones(self, db):
order = [s["name"] for s in await db.list_task_statuses()]
assert len(order) > 2

returned = await db.reorder_task_statuses([order[-1]])

# A status left out of the request must not vanish from the board.
assert [s["name"] for s in returned][0] == order[-1]
assert set(s["name"] for s in returned) == set(order)

async def test_unknown_names_are_ignored(self, db):
order = [s["name"] for s in await db.list_task_statuses()]

returned = await db.reorder_task_statuses(["not_a_status", *order])

assert [s["name"] for s in returned] == order

async def test_sort_order_is_contiguous_from_zero(self, db):
order = [s["name"] for s in await db.list_task_statuses()]
returned = await db.reorder_task_statuses(list(reversed(order)))
assert [s["sort_order"] for s in returned] == list(range(len(order)))
4 changes: 4 additions & 0 deletions web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,10 @@ export const api = {
request<TaskStatusDef>('/task-statuses', { method: 'POST', body: JSON.stringify(data) }),
updateTaskStatus: (name: string, data: { label?: string; color?: string; description?: string; sort_order?: number }) =>
request<TaskStatusDef>(`/task-statuses/${encodeURIComponent(name)}`, { method: 'PATCH', body: JSON.stringify(data) }),
reorderTaskStatuses: (names: string[]) =>
request<{ statuses: TaskStatusDef[] }>('/task-statuses/reorder', {
method: 'POST', body: JSON.stringify({ names }),
}),
deleteTaskStatus: (name: string) =>
request<{ name: string; deleted: boolean }>(`/task-statuses/${encodeURIComponent(name)}`, { method: 'DELETE' }),

Expand Down
53 changes: 49 additions & 4 deletions web/src/components/Tasks/Board/BoardColumn.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react';
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ChevronLeft, ChevronRight, GripVertical, Plus } from 'lucide-react';
import { columnDragId } from './dropIntent';
import type { Task, TaskStatusDef } from '../../../api/client';
import type { Lane } from '../../../stores/taskStore';
import { BoardCard } from './BoardCard';
Expand All @@ -26,13 +28,50 @@ export function BoardColumn({
data: { type: 'lane', status: lane.status },
});

// The column is itself sortable, but only by its header: the body has to
// stay a drop target for cards, and putting drag listeners on the whole
// column would make every card drag also pick up its column.
const {
setNodeRef: setColumnRef,
attributes: columnAttributes,
listeners: columnListeners,
transform: columnTransform,
transition: columnTransition,
isDragging: isColumnDragging,
} = useSortable({
id: columnDragId(lane.status),
data: { type: 'column', status: lane.status },
});

const columnStyle = {
transform: CSS.Translate.toString(columnTransform),
transition: columnTransition,
};
const dragHandle = (
<span
{...columnAttributes}
{...columnListeners}
title="Drag to reorder column"
aria-label={`Reorder ${status?.label ?? lane.status} column`}
className="text-text-faint hover:text-text-muted cursor-grab active:cursor-grabbing shrink-0"
>
<GripVertical size={13} />
</span>
);

const label = status?.label ?? lane.status;
const color = status?.color ?? '#6b7280';
const hidden = lane.total - lane.tasks.length;

if (collapsed) {
return (
<div className="w-11 shrink-0 flex flex-col items-center gap-3 py-3 bg-surface-raised/40 border border-border-subtle rounded-xl">
<div
ref={setColumnRef}
style={columnStyle}
className={`w-11 shrink-0 flex flex-col items-center gap-3 py-3 bg-surface-raised/40 border border-border-subtle rounded-xl
${isColumnDragging ? 'opacity-40' : ''}`}
>
{dragHandle}
<button
onClick={() => onToggleCollapse(lane.status)}
className="text-text-faint hover:text-text-muted cursor-pointer"
Expand All @@ -56,9 +95,15 @@ export function BoardColumn({
}

return (
<div className="w-[300px] shrink-0 flex flex-col bg-surface-raised/40 border border-border-subtle rounded-xl max-h-full">
<div
ref={setColumnRef}
style={columnStyle}
className={`w-[300px] shrink-0 flex flex-col bg-surface-raised/40 border border-border-subtle rounded-xl max-h-full
${isColumnDragging ? 'opacity-40' : ''}`}
>
<div className="shrink-0 px-3 py-2.5 border-b border-border-subtle">
<div className="flex items-center gap-2">
{dragHandle}
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: color }} />
<h2 className="text-[13px] font-semibold text-text-secondary truncate">{label}</h2>
<span className="text-[11px] text-text-faint tabular-nums">{lane.total}</span>
Expand Down
50 changes: 44 additions & 6 deletions web/src/components/Tasks/Board/TaskBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,23 @@ import {
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import {
SortableContext,
horizontalListSortingStrategy,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import type { Task } from '../../../api/client';
import { useTaskStatusStore } from '../../../stores/taskStatusStore';
import { useTaskStore } from '../../../stores/taskStore';
import { boardAnnouncements } from './announcements';
import { BoardCardOverlay } from './BoardCard';
import { isNoOpMove, resolveDropIntent } from './dropIntent';
import {
columnDragId,
isNoOpMove,
reorderStatuses,
resolveDropIntent,
statusFromDropTarget,
} from './dropIntent';
import { BoardColumn } from './BoardColumn';

const COLLAPSED_KEY = 'nerve_board_collapsed';
Expand Down Expand Up @@ -48,8 +58,10 @@ export function TaskBoard({ onOpenTask }: { onOpenTask: (task: Task) => void })
const setShowCreateDialog = useTaskStore((s) => s.setShowCreateDialog);
const searchQuery = useTaskStore((s) => s.searchQuery);
const statuses = useTaskStatusStore((s) => s.statuses);
const reorderColumns = useTaskStatusStore((s) => s.reorder);

const [activeTask, setActiveTask] = useState<Task | null>(null);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState<string[]>(readCollapsed);

const sensors = useSensors(
Expand Down Expand Up @@ -80,26 +92,42 @@ export function TaskBoard({ onOpenTask }: { onOpenTask: (task: Task) => void })
}, []);

const handleDragStart = useCallback((event: DragStartEvent) => {
const task = event.active.data.current?.task as Task | undefined;
setActiveTask(task ?? null);
const data = event.active.data.current;
if (data?.type === 'column') {
setActiveColumn(String(data.status));
return;
}
setActiveTask((data?.task as Task | undefined) ?? null);
}, []);

const handleDragEnd = useCallback((event: DragEndEvent) => {
setActiveTask(null);
setActiveColumn(null);
const { active, over } = event;
if (!over) return;

const activeId = String(active.id);
const overId = String(over.id);
if (activeId === overId) return;

// Columns and cards share one DndContext, so branch on what was picked
// up rather than on what it landed on.
if (active.data.current?.type === 'column') {
const moved = String(active.data.current.status);
const target = statusFromDropTarget(lanes, overId);
if (!target) return;
const next = reorderStatuses(lanes.map((l) => l.status), moved, target);
if (next) void reorderColumns(next);
return;
}

const intent = resolveDropIntent(lanes, activeId, overId);
if (!intent) return;
// Skip the round trip when the card was dropped back where it started.
if (isNoOpMove(lanes, activeId, intent)) return;

void moveTask(activeId, intent);
}, [lanes, moveTask]);
}, [lanes, moveTask, reorderColumns]);

if (boardLoading) {
return <div className="text-text-faint text-center py-10">Loading board...</div>;
Expand Down Expand Up @@ -132,11 +160,15 @@ export function TaskBoard({ onOpenTask }: { onOpenTask: (task: Task) => void })
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveTask(null)}
onDragCancel={() => { setActiveTask(null); setActiveColumn(null); }}
accessibility={{ announcements }}
>
<div className="flex-1 min-h-0 overflow-x-auto overflow-y-hidden px-4 pb-4">
<div className="flex gap-3 h-full items-start min-w-min">
<SortableContext
items={lanes.map((l) => columnDragId(l.status))}
strategy={horizontalListSortingStrategy}
>
{lanes.map((lane) => (
<BoardColumn
key={lane.status}
Expand All @@ -149,11 +181,17 @@ export function TaskBoard({ onOpenTask }: { onOpenTask: (task: Task) => void })
onOpenTask={onOpenTask}
/>
))}
</SortableContext>
</div>
</div>

<DragOverlay dropAnimation={null}>
{activeTask && <BoardCardOverlay task={activeTask} />}
{activeColumn && (
<div className="w-[300px] px-3 py-2.5 bg-surface-raised border border-accent/50 rounded-xl shadow-xl text-[13px] font-semibold text-text-secondary">
{statusByName.get(activeColumn)?.label ?? activeColumn}
</div>
)}
</DragOverlay>
</DndContext>
</>
Expand Down
27 changes: 27 additions & 0 deletions web/src/components/Tasks/Board/announcements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ const overCard = (id: string, title: string) =>
const lane = (status: string) =>
({ id: `lane:${status}`, data: { current: { type: 'lane', status } } }) as unknown as Over;

const column = (status: string) =>
({ id: `col:${status}`, data: { current: { type: 'column', status } } }) as unknown as Active;

describe('boardAnnouncements', () => {
const dragged = card('2026-08-05-fix-the-encoder', 'Fix the encoder');

Expand Down Expand Up @@ -79,3 +82,27 @@ describe('boardAnnouncements', () => {
);
});
});

describe('boardAnnouncements for a column drag', () => {
// Columns and cards share one DndContext, so `active` is not always a
// task. Assuming it was announced "Picked up task col:pending."
const dragged = column('pending');

it('says a column was picked up, not a task called col:something', () => {
expect(a.onDragStart({ active: dragged })).toBe('Picked up the Pending column.');
});

it('describes both ends of a column-over-column drag', () => {
expect(a.onDragOver({ active: dragged, over: column('in_progress') as unknown as Over })).toBe(
'The Pending column is over the In Progress column.',
);
});

it('describes a column dropped onto a card it landed over', () => {
// Collision detection returns the nearest droppable of any kind, so a
// column drag routinely finishes with the cursor over a card.
expect(a.onDragEnd({ active: dragged, over: overCard('t9', 'Some card') })).toBe(
'The Pending column dropped on task Some card.',
);
});
});
Loading
Loading