diff --git a/nerve/db/task_statuses.py b/nerve/db/task_statuses.py index 018781aa..c77148b3 100644 --- a/nerve/db/task_statuses.py +++ b/nerve/db/task_statuses.py @@ -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)], + ) + + 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,) diff --git a/nerve/gateway/routes/tasks.py b/nerve/gateway/routes/tasks.py index f246dac5..21c7f039 100644 --- a/nerve/gateway/routes/tasks.py +++ b/nerve/gateway/routes/tasks.py @@ -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), diff --git a/tests/test_task_board_api.py b/tests/test_task_board_api.py index 99f8161c..927ad6c3 100644 --- a/tests/test_task_board_api.py +++ b/tests/test_task_board_api.py @@ -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): diff --git a/tests/test_task_statuses.py b/tests/test_task_statuses.py index 68bbf127..3c8ab824 100644 --- a/tests/test_task_statuses.py +++ b/tests/test_task_statuses.py @@ -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))) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2cc2ff91..e67d3d2f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -432,6 +432,10 @@ export const api = { request('/task-statuses', { method: 'POST', body: JSON.stringify(data) }), updateTaskStatus: (name: string, data: { label?: string; color?: string; description?: string; sort_order?: number }) => request(`/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' }), diff --git a/web/src/components/Tasks/Board/BoardColumn.tsx b/web/src/components/Tasks/Board/BoardColumn.tsx index 46a03cda..69f5e908 100644 --- a/web/src/components/Tasks/Board/BoardColumn.tsx +++ b/web/src/components/Tasks/Board/BoardColumn.tsx @@ -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'; @@ -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 = ( + + + + ); + const label = status?.label ?? lane.status; const color = status?.color ?? '#6b7280'; const hidden = lane.total - lane.tasks.length; if (collapsed) { return ( -
+
+ {dragHandle}