#119 — Explicit "Sort mode" toggle with drag handles across all list types #139
Labels
No labels
priority/could
priority/must
priority/should
priority/wont
status/blocked
status/claimed
status/done-migrated
type/bug
type/feature
type/infra
type/tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/todo#139
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Story: Explicit "Sort mode" toggle with drag handles across all list types
As a list member,
I want to hide drag handles by default and reveal them via a "Sort mode" toggle next to the filter icon —
with dragging that also works across categories, and that behaves the same way on every list type that
supports manual order —
so that the everyday reading UI stays clean and touch-safe (no accidental drags), and when I do want to
reorder something I always know where the switch is regardless of which list type I'm in.
Motivation (verified against the current code)
The standard todo list already has proper
@dnd-kitdrag-and-drop today (grip icon on the left of everyrow —
ReactUi/src/components/TodoItem.tsx:222, wired up inTodoList.tsx:139). What it does not have:canReorderTodos(store.ts:453) hides the handle wheneverany filter or sort override is active — the default view already has
filterStatus: 'Open', whichqualifies as an active filter, so the handle disappears the moment a user does almost anything with the
"⋯" menu. The failure mode ("drag doesn't work") is the handle silently being absent, not a broken drag.
TodoList.tsx:163scopes each category to its ownDndContexton purpose (seethe comment at
TodoList.tsx:95), so a todo cannot be dragged into a different category — only throughthe category picker in the "⋯" menu.
The shopping list solved the cross-category problem in
#106already. This story generalises that fix andreplaces the implicit "handle visible only when nothing is filtered or sorted" rule with an explicit,
discoverable Sort mode toggle placed next to the filter icon.
There is no double-click-and-move mechanism in the current codebase; the PO's original framing of "replace
the double-click and move mechanism" resolves to "replace the current always-on-but-frequently-invisible
handle with a togglable one".
Acceptance criteria
TodoListHeader.tsximmediately next to the existingfilter icon (before the "⋯" menu). Icon is
GripVerticalfrom lucide — visually matches the draghandle it reveals.
Standard Todo, Project, Shopping. It does not appear on Priority
(formula-only ordering stays authoritative), Pantry (no manual-order use case), or
Master Packing — the latter is deferred to follow-up story
#119b(needs a newbackend reorder command; see design doc's "Scope adjustment discovered during design").
not shared between users (matches how
filterBarOpenbehaves inTodoList.tsx:33).shift when handles are absent (reserve the space, or animate in/out cleanly — no content jump on
toggle).
GripVerticalhandle at the start of the row. The handle is theonly drag surface — the row body itself must not be draggable — so tapping a row still opens/edits
as usual.
visually disabled, and the text filter plus the status/priority/assignee/due filters are
disabled. Applying any of them (via keyboard shortcut or otherwise) auto-turns Sort mode off.
Reason:
sortOrderis a per-todo integer on the persisted list, and dragging a filtered subsetwould either silently no-op or corrupt the hidden items' positions — see the
canReorderTodoscomment at
store.ts:448.category section moves it to that category at the drop position. Implementation reuses the same
approach as shopping list
#106— a single sharedDndContextaround all category sections,dispatching whichever backend command already handles category assignment (
CategoryPickertodaycalls it via
UpdateTodoCommandwith a newcategoryId; verify this is atomic with the reorder orchain the two calls with an optimistic UI update, same pattern as
TodoList.tsx:117handleDragEnd).PointerSensoractivation distance fromTodoList.tsx:39is preserved so a quick tap on the handle never initiates a drag on mobile.selectedTodoList.isArchived) and is authorized the sameway as other todo mutations — no auth surface changes.
(
ReorderTodosCommandand the category-assignment command remain as-is).#106). After thisstory, they become toggle-gated too, for consistency. Cross-category drag itself is unchanged —
only the discovery surface (toggle vs. always-on) changes.
text-primarytreatment used forfilterBarOpeninTodoListHeader.tsx:86), so a user can tell at a glance whether Sort mode is on.#17) is updated to open Sort mode first beforedragging, and a new E2E covers the cross-category drag on a Standard list.
Decisions locked in with the PO (2026-08-13)
remains the only sort. Rationale: the list type's identity is the Urgency+factor·Importance ordering;
a manual override would defeat the purpose.
regression, because UX consistency across list types is the whole reason for this story.
its header uncluttered.
GripVertical, visually matching the handle it reveals — not the more genericArrowUpDown.Out of scope for this story
#17).ReorderTodosCommand,UpdateTodoCommand, or the category-assignment API.Blockers
None. All prerequisites exist:
@dnd-kit/sortableis already in use acrossTodoList,ShoppingListPage, andPriorityMatrixCanvas.ShoppingListPage.tsx(#106) — the pattern transfers.CategoryPickeris already the same one that would be calledon drop.
Priority
Should — the user's framing is a UX-defect ("cannot be sorted", "does not work"), not a
nice-to-have. Small blast radius (frontend-only if the existing category-assignment path can be reused)
and directly improves the daily interaction with the most-used list type.
design (
119_sort_mode_toggle_with_drag_handles_design.md)Design:
#119— Sort-mode toggle with drag handlesSummary
Frontend-only change. The backend already has both commands this story needs:
ReorderTodosCommand(same-category reorder) andAssignTodoCategoryCommand(cross-category move + reorder, atomic — closes the gap in the old category and
reindexes the new one in a single transaction). Both are already exposed via
callApi(...), already authorized viaAuthorizeTodoListAccessForCurrentUserQuery + AuthorizeTodoListIsNotArchivedQuery,and already broadcast per-todo change events via
ChangePublisher<TodoId, TodoDto>.Shopping list solved the same problem in
#106using the shared helpers inReactUi/src/utils/reorder.ts; this story generalises those helpers and appliesthem to the standard todo list, then reworks the discoverability across all list
types that support manual order.
Scope adjustment discovered during design
The story locked in "toggle appears on Standard, Project, Master Packing, Shopping".
Verifying against the code:
TodoList.tsxand useReorderTodosCommand+AssignTodoCategoryCommand. Ready.#106and its own
MoveShoppingProductCommand. Only the toggle-gating needs to beadded.
(
ls CqsTodo/Features/MasterPacking/shows no reorder/move command; items aresorted purely by insertion
SortOrder). Adding manual sort here would require anew
ReorderMasterPackItemsCommand, a handler, tests, and DTO/API surface work— a materially bigger change than the frontend-only rest of this story.
Decision (Architect + PO reconciling with the story doc): Master Packing is
deferred from this story. The story now covers Standard, Project, Shopping only.
The MasterPack deferral is captured as a follow-up story
#119b(backlog, Could) —its use case is real ("always pack the passport first") but it's not the pain the
current story is fixing, and adding a new backend command would double the story's
scope. This mirrors the pattern where
#91spun off#91bfor the 2D-scatter view.Component-level design
TodoListHeader.tsx— new Sort-mode togglesortModeOn: booleanandonToggleSortMode: () => void.GripVerticalfrom lucide (per PO decision#4).text-primarytreatment asfilterBarOpen(seeTodoListHeader.tsx:86).aria-pressed={sortModeOn}and a label like"Sort mode — ${sortModeOn ? 'exit' : 'enter'}".The toggle button must be rendered conditionally — the header is currently
also rendered from
PriorityMatrixPage.tsx, and we do not want the toggle toappear on the Priority list per decision
#1. Cleanest: a new optional propsortMode?: { on: boolean; onToggle: () => void }. If undefined, no togglerenders; if defined, the button appears. This lets each page owner
opt in.
TodoList.tsx— sortMode state + shared DndContext + cross-category dragState:
const [sortModeOn, setSortModeOn] = useState(false);at the pagelevel. Session-scoped (resets on component remount / list switch, which happens
via the existing
useEffect(..., [selectedTodoList?.id])block).Drag-enabled derived value: replaces
canReorderfromstore.ts. New rule:canReorderTodosinstore.tsbecomes unused for this page; leave it in placefor now — its exports may still be referenced (verify with grep). If nothing else
uses it, remove it as cleanup within the same commit.
Auto-off interaction with sort/filter: while
sortModeOnis true, applyingany of
sortOrder / sortByDueDate / sortByPriority / filterText / filterStatus != 'All' / filterPriority != 'All' / filterAssignee != 'All' / filterDue != 'All'must be prevented. Approach:
TodoListHeaderdisables the corresponding menuitems visually while Sort mode is on; the underlying store setters are unchanged.
Also: opening the filter bar (
filterBarOpen) is allowed but the fields itshows are also disabled.
Shared DndContext: replace the current per-category
DndContextloop with asingle one wrapping every category section, matching
ShoppingListPage.tsx:380.Each category section wraps its
SortableContextin auseDroppablecontainer(via a small local
DroppableCategorySection, same asShoppingListPage.tsx:39) so an empty category remains a drop target whileisDragActiveis true.Drop resolution: reuse
resolveCrossCategoryMoveTargetfromutils/reorder.ts. That helper is currently typed forShoppingProductDtowithnumeric
activeId, whereas todos have a composite dnd-kit id${todoListId}-${nr}(a string). Two options:the
computeReorderCorepattern already in the same file. Cleaner long-term,small refactor touching Shopping's call site.
resolveTodoCrossCategoryMoveTarget. Faster,duplicates ~30 lines.
Choose A. Same author, same file, same pattern already established by
computeReorderCore. Renames the shopping helper's call signature slightly; thecall site is one function in one file (
ShoppingListPage.tsx:242).Category-id normalisation: todo
categoryIdisnumber | null(fromTodoDto.categoryId). The synthetic "uncategorized" bucket inTodoList.tsxuses
-1. TheAssignTodoCategoryCommandrequires a realTodoCategoryId(>=1),so dragging out of the uncategorized bucket into a real category is
supported (fine — the source of the move has no constraint), but dragging into
the uncategorized bucket must be a no-op (matches shopping's own comment at
ShoppingListPage.tsx:319). Enforce by not wrapping the uncategorizedsection in
DroppableCategorySection.Drop handler:
Note: I'm reusing the WS change stream (
subscribeToDtoChanges) instead of arefetch, unlike Shopping which owns its own list. Todos flow through
ChangePublisher<TodoId, TodoDto>andAssignTodoCategoryCommandbroadcastsevery touched todo — the same mechanism the existing UI already relies on. This
also means less duplicated work than Shopping's
refreshProducts()calls.TodoItem.tsx— no change neededThe
dragDisabled?: booleanprop and the!dragDisabled &&gate around theGripVerticalhandle atTodoItem.tsx:222already do exactly what we need.We just pass
dragDisabled={!dragEnabled}fromTodoList.tsxwith the newdragEnabled(which now depends onsortModeOninstead ofcanReorderTodos).PriorityMatrixPage.tsx— no toggleDo not pass
sortModetoTodoListHeader. The Priority list continues to haveno manual sort at all — the formula stays authoritative (decision
#1).ShoppingListPage.tsx— add sortMode toggle, gate handlesShopping doesn't render
TodoListHeader; it has its own inline header. Add asortModeOnstate and aGripVerticaltoggle button in that inline header (seeShoppingListPage.tsx:334). PassdragDisabled={!sortModeOn}toShoppingProductItem(which already accepts this prop).PantryPage.tsx— unchangedPer decision
#3. Its drag handles remain always-visible; no toggle. The story'sinconsistency-cost of doing this was accepted by the PO.
MasterPackingListPage.tsx— deferred to#119bSee scope adjustment above.
Files changed
ReactUi/src/components/TodoListHeader.tsx— new optionalsortModeprop, newbutton, disable other sort/filter menu items while active.
ReactUi/src/components/TodoList.tsx— sortMode state, shared DndContext,cross-category drop, replace
canReordergating.ReactUi/src/components/ShoppingListPage.tsx— sortMode state, header button,wire to existing
dragDisabledprop.ReactUi/src/utils/reorder.ts— parametriseresolveCrossCategoryMoveTargeton id shape.
ReactUi/src/store.ts— removecanReorderTodosif unused elsewhere afterthe change (grep first).
ReactUi/src/components/TodoListHeader.test.tsx— cover toggle presence,aria-pressed, and disabled-state of sort/filter items while active.ReactUi/src/components/TodoList.test.tsx(new if missing) or unit test ofthe drop handler — cover same-category vs cross-category branches.
ReactUi/src/utils/reorder.test.ts— extend for the parametrised helper.ReactUi/tests/e2e/reorder.spec.ts(or wherever the#17E2E lives) — openSort mode before dragging.
What does NOT change
existing offline path if applicable).
Security notes (for pre-review)
AssignTodoCategoryCommandandReorderTodosCommandare already gatedby
AuthorizeTodoListAccessForCurrentUserQuery(member of the list) andAuthorizeTodoListIsNotArchivedQuery(not read-only). No new attack surface.a debugger could still fire the reorder API directly, but that was already
true before this story and is still authorized identically. No change.
access to for the same list; the handler already validates the category
belongs to the list (
AssignTodoCategoryCommandHandler.cs:42).Test plan
TodoListHeaderrenders the toggle whensortModeprop is provided;button toggles
sortModeOnviaonToggleSortMode; sort/filter menu itemsare disabled when
sortModeOnis true.reorder.tsresolveCrossCategoryMoveTarget— new tests for theparametrised signature, covering todo-shaped ids (
"12-3"etc.) alongsidethe existing shopping-shaped numeric-id tests.
TodoListdrop handler — mockcallApi, assert the correct commandis dispatched with the correct payload for same-category and cross-category
drops.
assert both source and target category orders and the moved todo's new
category.
tsc -b,vitest run,dotnet test,playwright test,docker build(per user's
feedback_playwright_and_test_gates.md).