From 77c68fcbed2621857df626ef3f6fd0dfa33be59e Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:50:37 +0000 Subject: [PATCH 1/2] Let board columns be reordered by dragging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Column order was fixed at whatever sort_order the statuses happened to have, with no way to change it from the UI — and lane order is the part of a board people arrange to match how they actually work. Columns are now sortable in the same DndContext as the cards, branching on what was picked up rather than on what it landed on. Two details make that work: Drag listeners live on the header only, not the whole column. The column body has to stay a drop target for cards, and listeners on the wrapper would make every card drag also pick up its column. Column ids are namespaced ("col:") so they can't be confused with a task id inside the shared context, and a drop resolves through any kind of target — another column, a lane body, or a card. Collision detection returns the nearest droppable of any kind, so a column drag routinely finishes with the cursor over a card; treating that as "no target" would make the gesture feel broken. The API takes the whole desired sequence rather than one status's new index. A drag shifts several statuses at once, so a full-sequence write is idempotent and avoids the half-applied states a per-row PATCH storm would leave behind if one failed. Statuses omitted from the request keep their relative order after the named ones, so a status can't be dropped from the board by an incomplete call. Co-Authored-By: Claude Opus 5 --- nerve/db/task_statuses.py | 28 ++++++++ nerve/gateway/routes/tasks.py | 18 +++++ tests/test_task_board_api.py | 31 ++++++++ tests/test_task_statuses.py | 50 +++++++++++++ web/src/api/client.ts | 4 ++ .../components/Tasks/Board/BoardColumn.tsx | 53 ++++++++++++-- web/src/components/Tasks/Board/TaskBoard.tsx | 50 +++++++++++-- .../Tasks/Board/dropIntent.test.tsx | 71 ++++++++++++++++++- web/src/components/Tasks/Board/dropIntent.ts | 49 +++++++++++++ web/src/stores/taskStatusStore.ts | 23 ++++++ 10 files changed, 366 insertions(+), 11 deletions(-) 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}