#119 — Explicit "Sort mode" toggle with drag handles across all list types #139

Closed
opened 2026-08-18 13:29:49 +02:00 by lena · 1 comment
lena commented 2026-08-18 13:29:49 +02:00 (Migrated from git.butzei.de)

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-kit drag-and-drop today (grip icon on the left of every
row — ReactUi/src/components/TodoItem.tsx:222, wired up in TodoList.tsx:139). What it does not have:

  • A predictable way to reach the handle. canReorderTodos (store.ts:453) hides the handle whenever
    any filter or sort override is active — the default view already has filterStatus: 'Open', which
    qualifies 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.
  • Cross-category drag. TodoList.tsx:163 scopes each category to its own DndContext on purpose (see
    the comment at TodoList.tsx:95), so a todo cannot be dragged into a different category — only through
    the category picker in the "⋯" menu.

The shopping list solved the cross-category problem in #106 already. This story generalises that fix and
replaces 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

  • A new Sort mode toggle button appears in TodoListHeader.tsx immediately next to the existing
    filter icon (before the "⋯" menu). Icon is GripVertical from lucide — visually matches the drag
    handle it reveals.
  • Toggle only appears on list types that support manual order:
    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 new
    backend reorder command; see design doc's "Scope adjustment discovered during design").
  • Toggle is off by default every time a list is opened — session state, not persisted server-side,
    not shared between users (matches how filterBarOpen behaves in TodoList.tsx:33).
  • When off: no todo row shows a drag handle and no row is draggable. Row layout must not visually
    shift when handles are absent (reserve the space, or animate in/out cleanly — no content jump on
    toggle).
  • When on: every row shows the GripVertical handle at the start of the row. The handle is the
    only drag surface — the row body itself must not be draggable — so tapping a row still opens/edits
    as usual.
  • While Sort mode is on, the alphabetical / due-date / priority sort options in the "⋯" menu are
    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: sortOrder is a per-todo integer on the persisted list, and dragging a filtered subset
    would either silently no-op or corrupt the hidden items' positions — see the canReorderTodos
    comment at store.ts:448.
  • Cross-category drag works for Standard and Project todo lists: dropping a todo into a different
    category section moves it to that category at the drop position. Implementation reuses the same
    approach as shopping list #106 — a single shared DndContext around all category sections,
    dispatching whichever backend command already handles category assignment (CategoryPicker today
    calls it via UpdateTodoCommand with a new categoryId; verify this is atomic with the reorder or
    chain the two calls with an optimistic UI update, same pattern as TodoList.tsx:117
    handleDragEnd).
  • Drag works on both mouse and touch. The 8-px PointerSensor activation distance from
    TodoList.tsx:39 is preserved so a quick tap on the handle never initiates a drag on mobile.
  • Reordering is blocked on archived lists (selectedTodoList.isArchived) and is authorized the same
    way as other todo mutations — no auth surface changes.
  • Manual order survives page reload — nothing changes about server-side persistence
    (ReorderTodosCommand and the category-assignment command remain as-is).
  • Shopping list behaviour change: today its handles are always visible (from #106). After this
    story, they become toggle-gated too, for consistency. Cross-category drag itself is unchanged —
    only the discovery surface (toggle vs. always-on) changes.
  • The toggle button's active state is visually distinct (same text-primary treatment used for
    filterBarOpen in TodoListHeader.tsx:86), so a user can tell at a glance whether Sort mode is on.
  • Existing Playwright E2E for todo reordering (from #17) is updated to open Sort mode first before
    dragging, and a new E2E covers the cross-category drag on a Standard list.

Decisions locked in with the PO (2026-08-13)

  1. Priority list is out of scope for manual sort. Toggle simply does not appear there; the formula
    remains the only sort. Rationale: the list type's identity is the Urgency+factor·Importance ordering;
    a manual override would defeat the purpose.
  2. Shopping list gets the toggle applied uniformly, accepting the one-tap-more discoverability
    regression, because UX consistency across list types is the whole reason for this story.
  3. Pantry does not get the toggle. No known daily need for manual product order in the pantry; keep
    its header uncluttered.
  4. Icon: GripVertical, visually matching the handle it reveals — not the more generic
    ArrowUpDown.

Out of scope for this story

  • Keyboard-based reordering (arrow-keys-with-grab affordance).
  • Multi-select / batch drag of several todos at once.
  • Dragging between different lists.
  • Per-user custom order (order stays global/shared, last drag wins — same as #17).
  • Persisting the Sort-mode toggle state across reloads or across list switches.
  • Any change to ReorderTodosCommand, UpdateTodoCommand, or the category-assignment API.

Blockers

None. All prerequisites exist:

  • @dnd-kit/sortable is already in use across TodoList, ShoppingListPage, and
    PriorityMatrixCanvas.
  • Cross-category drag is a solved problem in ShoppingListPage.tsx (#106) — the pattern transfers.
  • The category-assignment API path used by CategoryPicker is already the same one that would be called
    on 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.

# 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-kit` drag-and-drop today (grip icon on the left of every row — `ReactUi/src/components/TodoItem.tsx:222`, wired up in `TodoList.tsx:139`). What it does **not** have: - **A predictable way to reach the handle.** `canReorderTodos` (`store.ts:453`) hides the handle whenever *any* filter or sort override is active — the default view already has `filterStatus: 'Open'`, which qualifies 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. - **Cross-category drag.** `TodoList.tsx:163` scopes each category to its own `DndContext` on purpose (see the comment at `TodoList.tsx:95`), so a todo cannot be dragged into a different category — only through the category picker in the "⋯" menu. The shopping list solved the cross-category problem in `#106` already. This story generalises that fix and replaces 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 - [ ] A new **Sort mode** toggle button appears in `TodoListHeader.tsx` immediately next to the existing filter icon (before the "⋯" menu). Icon is `GripVertical` from lucide — visually matches the drag handle it reveals. - [ ] Toggle **only appears** on list types that support manual order: **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 new backend reorder command; see design doc's "Scope adjustment discovered during design"). - [ ] Toggle is **off by default** every time a list is opened — session state, not persisted server-side, not shared between users (matches how `filterBarOpen` behaves in `TodoList.tsx:33`). - [ ] When **off**: no todo row shows a drag handle and no row is draggable. Row layout must not visually shift when handles are absent (reserve the space, or animate in/out cleanly — no content jump on toggle). - [ ] When **on**: every row shows the `GripVertical` handle at the start of the row. The handle is the only drag surface — the row body itself must not be draggable — so tapping a row still opens/edits as usual. - [ ] While Sort mode is **on**, the alphabetical / due-date / priority sort options in the "⋯" menu are 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: `sortOrder` is a per-todo integer on the persisted list, and dragging a filtered subset would either silently no-op or corrupt the hidden items' positions — see the `canReorderTodos` comment at `store.ts:448`. - [ ] **Cross-category drag** works for Standard and Project todo lists: dropping a todo into a different category section moves it to that category at the drop position. Implementation reuses the same approach as shopping list `#106` — a single shared `DndContext` around all category sections, dispatching whichever backend command already handles category assignment (`CategoryPicker` today calls it via `UpdateTodoCommand` with a new `categoryId`; verify this is atomic with the reorder or chain the two calls with an optimistic UI update, same pattern as `TodoList.tsx:117` `handleDragEnd`). - [ ] Drag works on both **mouse and touch**. The 8-px `PointerSensor` activation distance from `TodoList.tsx:39` is preserved so a quick tap on the handle never initiates a drag on mobile. - [ ] Reordering is blocked on archived lists (`selectedTodoList.isArchived`) and is authorized the same way as other todo mutations — no auth surface changes. - [ ] Manual order survives page reload — nothing changes about server-side persistence (`ReorderTodosCommand` and the category-assignment command remain as-is). - [ ] **Shopping list behaviour change:** today its handles are always visible (from `#106`). After this story, they become toggle-gated too, for consistency. Cross-category drag itself is unchanged — only the discovery surface (toggle vs. always-on) changes. - [ ] The toggle button's active state is visually distinct (same `text-primary` treatment used for `filterBarOpen` in `TodoListHeader.tsx:86`), so a user can tell at a glance whether Sort mode is on. - [ ] Existing Playwright E2E for todo reordering (from `#17`) is updated to open Sort mode first before dragging, and a new E2E covers the cross-category drag on a Standard list. ## Decisions locked in with the PO (2026-08-13) 1. **Priority list is out of scope for manual sort.** Toggle simply does not appear there; the formula remains the only sort. Rationale: the list type's identity is the Urgency+factor·Importance ordering; a manual override would defeat the purpose. 2. **Shopping list gets the toggle applied uniformly**, accepting the one-tap-more discoverability regression, because UX consistency across list types is the whole reason for this story. 3. **Pantry does not get the toggle.** No known daily need for manual product order in the pantry; keep its header uncluttered. 4. **Icon: `GripVertical`**, visually matching the handle it reveals — not the more generic `ArrowUpDown`. ## Out of scope for this story - Keyboard-based reordering (arrow-keys-with-grab affordance). - Multi-select / batch drag of several todos at once. - Dragging between different lists. - Per-user custom order (order stays global/shared, last drag wins — same as `#17`). - Persisting the Sort-mode toggle state across reloads or across list switches. - Any change to `ReorderTodosCommand`, `UpdateTodoCommand`, or the category-assignment API. ## Blockers None. All prerequisites exist: - `@dnd-kit/sortable` is already in use across `TodoList`, `ShoppingListPage`, and `PriorityMatrixCanvas`. - Cross-category drag is a solved problem in `ShoppingListPage.tsx` (`#106`) — the pattern transfers. - The category-assignment API path used by `CategoryPicker` is already the same one that would be called on 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.
lena commented 2026-08-18 13:29:49 +02:00 (Migrated from git.butzei.de)

design (119_sort_mode_toggle_with_drag_handles_design.md)

Design: #119 — Sort-mode toggle with drag handles

Summary

Frontend-only change. The backend already has both commands this story needs:
ReorderTodosCommand (same-category reorder) and AssignTodoCategoryCommand
(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 via
AuthorizeTodoListAccessForCurrentUserQuery + AuthorizeTodoListIsNotArchivedQuery,
and already broadcast per-todo change events via ChangePublisher<TodoId, TodoDto>.
Shopping list solved the same problem in #106 using the shared helpers in
ReactUi/src/utils/reorder.ts; this story generalises those helpers and applies
them 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:

  • Standard + Project share TodoList.tsx and use ReorderTodosCommand +
    AssignTodoCategoryCommand. Ready.
  • Shopping already has the shared-DndContext + cross-category pattern from #106
    and its own MoveShoppingProductCommand. Only the toggle-gating needs to be
    added.
  • Master Packing does not actually have any reorder backend today
    (ls CqsTodo/Features/MasterPacking/ shows no reorder/move command; items are
    sorted purely by insertion SortOrder). Adding manual sort here would require a
    new 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 #91 spun off #91b for the 2D-scatter view.

Component-level design

TodoListHeader.tsx — new Sort-mode toggle

  • New prop: sortModeOn: boolean and onToggleSortMode: () => void.
  • Placement: immediately after the existing filter button, before the "⋯" menu.
  • Icon: GripVertical from lucide (per PO decision #4).
  • Active state uses the same text-primary treatment as filterBarOpen (see
    TodoListHeader.tsx:86).
  • Aria: 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 to
appear on the Priority list per decision #1. Cleanest: a new optional prop
sortMode?: { on: boolean; onToggle: () => void }. If undefined, no toggle
renders; if defined, the button appears. This lets each page owner
opt in.

TodoList.tsx — sortMode state + shared DndContext + cross-category drag

State: const [sortModeOn, setSortModeOn] = useState(false); at the page
level. Session-scoped (resets on component remount / list switch, which happens
via the existing useEffect(..., [selectedTodoList?.id]) block).

Drag-enabled derived value: replaces canReorder from store.ts. New rule:

const dragEnabled = sortModeOn && !selectedTodoList.isArchived;

canReorderTodos in store.ts becomes unused for this page; leave it in place
for 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 sortModeOn is true, applying
any of sortOrder / sortByDueDate / sortByPriority / filterText / filterStatus != 'All' / filterPriority != 'All' / filterAssignee != 'All' / filterDue != 'All'
must be prevented. Approach: TodoListHeader disables the corresponding menu
items visually while Sort mode is on; the underlying store setters are unchanged.
Also: opening the filter bar (filterBarOpen) is allowed but the fields it
shows are also disabled.

Shared DndContext: replace the current per-category DndContext loop with a
single one wrapping every category section, matching ShoppingListPage.tsx:380.
Each category section wraps its SortableContext in a useDroppable container
(via a small local DroppableCategorySection, same as
ShoppingListPage.tsx:39) so an empty category remains a drop target while
isDragActive is true.

Drop resolution: reuse resolveCrossCategoryMoveTarget from
utils/reorder.ts. That helper is currently typed for ShoppingProductDto with
numeric activeId, whereas todos have a composite dnd-kit id
${todoListId}-${nr} (a string). Two options:

  • Option A: parametrise the helper on item shape + id extractors, matching
    the computeReorderCore pattern already in the same file. Cleaner long-term,
    small refactor touching Shopping's call site.
  • Option B: write a parallel 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; the
call site is one function in one file (ShoppingListPage.tsx:242).

Category-id normalisation: todo categoryId is number | null (from
TodoDto.categoryId). The synthetic "uncategorized" bucket in TodoList.tsx
uses -1. The AssignTodoCategoryCommand requires a real TodoCategoryId (>=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 uncategorized
section in DroppableCategorySection.

Drop handler:

const handleDragEnd = async (event: DragEndEvent) => {
    setIsDragActive(false);
    const {active, over} = event;
    if (!over) return;
    const target = resolveCrossCategoryMoveTarget(
        todosByCategory,
        String(active.id),
        String(over.id),
        todoId,
        t => t.nr,  // for locating source
    );
    if (!target) return;
    const {sourceCategoryId, targetCategoryId, overIndexInTarget} = target;

    if (sourceCategoryId < 0 && targetCategoryId < 0) return; // uncategorized -> uncategorized: no-op
    if (targetCategoryId < 0) return; // dropped into uncategorized: no-op (see above)

    if (sourceCategoryId === targetCategoryId) {
        // Same-category → reuse existing ReorderTodosCommand path.
        const list = todosByCategory.get(sourceCategoryId) ?? [];
        const oldIndex = list.findIndex(t => todoId(t) === String(active.id));
        const newIndex = overIndexInTarget ?? list.length - 1;
        if (oldIndex === -1 || oldIndex === newIndex) return;

        const reordered = arrayMove(list, oldIndex, newIndex);
        const orderedNrs = reordered.map(t => t.nr);
        reordered.forEach((t, i) => update({...t, sortOrder: i}));
        try {
            await callApi('ReorderTodosCommand', {
                todoListId: selectedTodoList.id,
                categoryId: sourceCategoryId,
                orderedNrs,
            });
        } catch {
            callApi('GetTodosOfCurrentUserQuery', {}).then(set);
        }
        return;
    }

    // Cross-category → AssignTodoCategoryCommand.
    const activeTodo = todos.find(t => todoId(t) === String(active.id));
    if (!activeTodo) return;
    // Optimistically move it in the local store, matching shopping's pattern.
    update({...activeTodo, categoryId: targetCategoryId as TodoCategoryId, sortOrder: overIndexInTarget ?? 0});
    try {
        await callApi('AssignTodoCategoryCommand', {
            todoListId: selectedTodoList.id,
            nr: activeTodo.nr,
            categoryId: targetCategoryId,
            sortOrder: overIndexInTarget ?? 2147483647, // "append" — same convention as shopping (Int32.MaxValue clamped server-side)
        });
        // AssignTodoCategoryCommand's WS broadcast pushes every touched todo,
        // so no manual refetch needed — same as ReorderTodosCommand path.
    } catch {
        callApi('GetTodosOfCurrentUserQuery', {}).then(set);
    }
};

Note: I'm reusing the WS change stream (subscribeToDtoChanges) instead of a
refetch, unlike Shopping which owns its own list. Todos flow through
ChangePublisher<TodoId, TodoDto> and AssignTodoCategoryCommand broadcasts
every 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 needed

The dragDisabled?: boolean prop and the !dragDisabled && gate around the
GripVertical handle at TodoItem.tsx:222 already do exactly what we need.
We just pass dragDisabled={!dragEnabled} from TodoList.tsx with the new
dragEnabled (which now depends on sortModeOn instead of canReorderTodos).

PriorityMatrixPage.tsx — no toggle

Do not pass sortMode to TodoListHeader. The Priority list continues to have
no manual sort at all — the formula stays authoritative (decision #1).

ShoppingListPage.tsx — add sortMode toggle, gate handles

Shopping doesn't render TodoListHeader; it has its own inline header. Add a
sortModeOn state and a GripVertical toggle button in that inline header (see
ShoppingListPage.tsx:334). Pass dragDisabled={!sortModeOn} to
ShoppingProductItem (which already accepts this prop).

PantryPage.tsx — unchanged

Per decision #3. Its drag handles remain always-visible; no toggle. The story's
inconsistency-cost of doing this was accepted by the PO.

MasterPackingListPage.tsx — deferred to #119b

See scope adjustment above.

Files changed

  • ReactUi/src/components/TodoListHeader.tsx — new optional sortMode prop, new
    button, disable other sort/filter menu items while active.
  • ReactUi/src/components/TodoList.tsx — sortMode state, shared DndContext,
    cross-category drop, replace canReorder gating.
  • ReactUi/src/components/ShoppingListPage.tsx — sortMode state, header button,
    wire to existing dragDisabled prop.
  • ReactUi/src/utils/reorder.ts — parametrise resolveCrossCategoryMoveTarget
    on id shape.
  • ReactUi/src/store.ts — remove canReorderTodos if unused elsewhere after
    the 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 of
    the 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 #17 E2E lives) — open
    Sort mode before dragging.
  • New E2E: cross-category drag on a standard todo list.

What does NOT change

  • No backend files.
  • No new API endpoint, no new DTO field, no new value object.
  • No auth/authorization change (uses existing gated commands).
  • No offline-write-queue change (the two commands already flow through the
    existing offline path if applicable).
  • No change to persisted server state schema or migrations.

Security notes (for pre-review)

  • Both AssignTodoCategoryCommand and ReorderTodosCommand are already gated
    by AuthorizeTodoListAccessForCurrentUserQuery (member of the list) and
    AuthorizeTodoListIsNotArchivedQuery (not read-only). No new attack surface.
  • Client-side "sortMode off" is a UX gate, not a security gate — a caller with
    a debugger could still fire the reorder API directly, but that was already
    true before this story and is still authorized identically. No change.
  • The cross-category drop path can send any category id the user's session has
    access to for the same list; the handler already validates the category
    belongs to the list (AssignTodoCategoryCommandHandler.cs:42).

Test plan

  1. Unit: TodoListHeader renders the toggle when sortMode prop is provided;
    button toggles sortModeOn via onToggleSortMode; sort/filter menu items
    are disabled when sortModeOn is true.
  2. Unit: reorder.ts resolveCrossCategoryMoveTarget — new tests for the
    parametrised signature, covering todo-shaped ids ("12-3" etc.) alongside
    the existing shopping-shaped numeric-id tests.
  3. Unit: TodoList drop handler — mock callApi, assert the correct command
    is dispatched with the correct payload for same-category and cross-category
    drops.
  4. E2E: existing reorder spec — open Sort mode first, then drag.
  5. E2E (new): open Sort mode, drag a todo from one category to another,
    assert both source and target category orders and the moved todo's new
    category.
  6. tsc -b, vitest run, dotnet test, playwright test, docker build
    (per user's feedback_playwright_and_test_gates.md).
**design** (`119_sort_mode_toggle_with_drag_handles_design.md`) # Design: `#119` — Sort-mode toggle with drag handles ## Summary Frontend-only change. The backend already has both commands this story needs: `ReorderTodosCommand` (same-category reorder) and `AssignTodoCategoryCommand` (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 via `AuthorizeTodoListAccessForCurrentUserQuery + AuthorizeTodoListIsNotArchivedQuery`, and already broadcast per-todo change events via `ChangePublisher<TodoId, TodoDto>`. Shopping list solved the same problem in `#106` using the shared helpers in `ReactUi/src/utils/reorder.ts`; this story generalises those helpers and applies them 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: - **Standard + Project** share `TodoList.tsx` and use `ReorderTodosCommand` + `AssignTodoCategoryCommand`. Ready. - **Shopping** already has the shared-DndContext + cross-category pattern from `#106` and its own `MoveShoppingProductCommand`. Only the toggle-gating needs to be added. - **Master Packing** does **not** actually have any reorder backend today (`ls CqsTodo/Features/MasterPacking/` shows no reorder/move command; items are sorted purely by insertion `SortOrder`). Adding manual sort here would require a new `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 `#91` spun off `#91b` for the 2D-scatter view. ## Component-level design ### `TodoListHeader.tsx` — new Sort-mode toggle - New prop: `sortModeOn: boolean` and `onToggleSortMode: () => void`. - Placement: immediately after the existing filter button, before the "⋯" menu. - Icon: `GripVertical` from lucide (per PO decision `#4`). - Active state uses the same `text-primary` treatment as `filterBarOpen` (see `TodoListHeader.tsx:86`). - Aria: `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 to appear on the Priority list per decision `#1`. Cleanest: a new optional prop `sortMode?: { on: boolean; onToggle: () => void }`. If undefined, no toggle renders; if defined, the button appears. This lets each page owner opt in. ### `TodoList.tsx` — sortMode state + shared DndContext + cross-category drag **State:** `const [sortModeOn, setSortModeOn] = useState(false);` at the page level. Session-scoped (resets on component remount / list switch, which happens via the existing `useEffect(..., [selectedTodoList?.id])` block). **Drag-enabled derived value:** replaces `canReorder` from `store.ts`. New rule: ```ts const dragEnabled = sortModeOn && !selectedTodoList.isArchived; ``` `canReorderTodos` in `store.ts` becomes unused for this page; leave it in place for 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 `sortModeOn` is true, applying any of `sortOrder / sortByDueDate / sortByPriority / filterText / filterStatus != 'All' / filterPriority != 'All' / filterAssignee != 'All' / filterDue != 'All'` must be prevented. Approach: `TodoListHeader` disables the corresponding menu items visually while Sort mode is on; the underlying store setters are unchanged. Also: opening the filter bar (`filterBarOpen`) is allowed but the fields it shows are also disabled. **Shared DndContext:** replace the current per-category `DndContext` loop with a single one wrapping every category section, matching `ShoppingListPage.tsx:380`. Each category section wraps its `SortableContext` in a `useDroppable` container (via a small local `DroppableCategorySection`, same as `ShoppingListPage.tsx:39`) so an empty category remains a drop target while `isDragActive` is true. **Drop resolution:** reuse `resolveCrossCategoryMoveTarget` from `utils/reorder.ts`. That helper is currently typed for `ShoppingProductDto` with numeric `activeId`, whereas todos have a composite dnd-kit id `${todoListId}-${nr}` (a string). Two options: - **Option A: parametrise the helper** on item shape + id extractors, matching the `computeReorderCore` pattern already in the same file. Cleaner long-term, small refactor touching Shopping's call site. - **Option B: write a parallel `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; the call site is one function in one file (`ShoppingListPage.tsx:242`). **Category-id normalisation:** todo `categoryId` is `number | null` (from `TodoDto.categoryId`). The synthetic "uncategorized" bucket in `TodoList.tsx` uses `-1`. The `AssignTodoCategoryCommand` requires a real `TodoCategoryId` (>=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 uncategorized section in `DroppableCategorySection`. **Drop handler:** ```ts const handleDragEnd = async (event: DragEndEvent) => { setIsDragActive(false); const {active, over} = event; if (!over) return; const target = resolveCrossCategoryMoveTarget( todosByCategory, String(active.id), String(over.id), todoId, t => t.nr, // for locating source ); if (!target) return; const {sourceCategoryId, targetCategoryId, overIndexInTarget} = target; if (sourceCategoryId < 0 && targetCategoryId < 0) return; // uncategorized -> uncategorized: no-op if (targetCategoryId < 0) return; // dropped into uncategorized: no-op (see above) if (sourceCategoryId === targetCategoryId) { // Same-category → reuse existing ReorderTodosCommand path. const list = todosByCategory.get(sourceCategoryId) ?? []; const oldIndex = list.findIndex(t => todoId(t) === String(active.id)); const newIndex = overIndexInTarget ?? list.length - 1; if (oldIndex === -1 || oldIndex === newIndex) return; const reordered = arrayMove(list, oldIndex, newIndex); const orderedNrs = reordered.map(t => t.nr); reordered.forEach((t, i) => update({...t, sortOrder: i})); try { await callApi('ReorderTodosCommand', { todoListId: selectedTodoList.id, categoryId: sourceCategoryId, orderedNrs, }); } catch { callApi('GetTodosOfCurrentUserQuery', {}).then(set); } return; } // Cross-category → AssignTodoCategoryCommand. const activeTodo = todos.find(t => todoId(t) === String(active.id)); if (!activeTodo) return; // Optimistically move it in the local store, matching shopping's pattern. update({...activeTodo, categoryId: targetCategoryId as TodoCategoryId, sortOrder: overIndexInTarget ?? 0}); try { await callApi('AssignTodoCategoryCommand', { todoListId: selectedTodoList.id, nr: activeTodo.nr, categoryId: targetCategoryId, sortOrder: overIndexInTarget ?? 2147483647, // "append" — same convention as shopping (Int32.MaxValue clamped server-side) }); // AssignTodoCategoryCommand's WS broadcast pushes every touched todo, // so no manual refetch needed — same as ReorderTodosCommand path. } catch { callApi('GetTodosOfCurrentUserQuery', {}).then(set); } }; ``` Note: I'm reusing the WS change stream (`subscribeToDtoChanges`) instead of a refetch, unlike Shopping which owns its own list. Todos flow through `ChangePublisher<TodoId, TodoDto>` and `AssignTodoCategoryCommand` broadcasts every 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 needed The `dragDisabled?: boolean` prop and the `!dragDisabled &&` gate around the `GripVertical` handle at `TodoItem.tsx:222` already do exactly what we need. We just pass `dragDisabled={!dragEnabled}` from `TodoList.tsx` with the new `dragEnabled` (which now depends on `sortModeOn` instead of `canReorderTodos`). ### `PriorityMatrixPage.tsx` — no toggle Do not pass `sortMode` to `TodoListHeader`. The Priority list continues to have no manual sort at all — the formula stays authoritative (decision `#1`). ### `ShoppingListPage.tsx` — add sortMode toggle, gate handles Shopping doesn't render `TodoListHeader`; it has its own inline header. Add a `sortModeOn` state and a `GripVertical` toggle button in that inline header (see `ShoppingListPage.tsx:334`). Pass `dragDisabled={!sortModeOn}` to `ShoppingProductItem` (which already accepts this prop). ### `PantryPage.tsx` — unchanged Per decision `#3`. Its drag handles remain always-visible; no toggle. The story's inconsistency-cost of doing this was accepted by the PO. ### `MasterPackingListPage.tsx` — deferred to `#119b` See scope adjustment above. ## Files changed - `ReactUi/src/components/TodoListHeader.tsx` — new optional `sortMode` prop, new button, disable other sort/filter menu items while active. - `ReactUi/src/components/TodoList.tsx` — sortMode state, shared DndContext, cross-category drop, replace `canReorder` gating. - `ReactUi/src/components/ShoppingListPage.tsx` — sortMode state, header button, wire to existing `dragDisabled` prop. - `ReactUi/src/utils/reorder.ts` — parametrise `resolveCrossCategoryMoveTarget` on id shape. - `ReactUi/src/store.ts` — remove `canReorderTodos` if unused elsewhere after the 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 of the 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 `#17` E2E lives) — open Sort mode before dragging. - **New** E2E: cross-category drag on a standard todo list. ## What does NOT change - No backend files. - No new API endpoint, no new DTO field, no new value object. - No auth/authorization change (uses existing gated commands). - No offline-write-queue change (the two commands already flow through the existing offline path if applicable). - No change to persisted server state schema or migrations. ## Security notes (for pre-review) - Both `AssignTodoCategoryCommand` and `ReorderTodosCommand` are already gated by `AuthorizeTodoListAccessForCurrentUserQuery` (member of the list) and `AuthorizeTodoListIsNotArchivedQuery` (not read-only). No new attack surface. - Client-side "sortMode off" is a UX gate, not a security gate — a caller with a debugger could still fire the reorder API directly, but that was already true before this story and is still authorized identically. No change. - The cross-category drop path can send any category id the user's session has access to for the same list; the handler already validates the category belongs to the list (`AssignTodoCategoryCommandHandler.cs:42`). ## Test plan 1. Unit: `TodoListHeader` renders the toggle when `sortMode` prop is provided; button toggles `sortModeOn` via `onToggleSortMode`; sort/filter menu items are disabled when `sortModeOn` is true. 2. Unit: `reorder.ts` `resolveCrossCategoryMoveTarget` — new tests for the parametrised signature, covering todo-shaped ids (`"12-3"` etc.) alongside the existing shopping-shaped numeric-id tests. 3. Unit: `TodoList` drop handler — mock `callApi`, assert the correct command is dispatched with the correct payload for same-category and cross-category drops. 4. E2E: existing reorder spec — open Sort mode first, then drag. 5. E2E (new): open Sort mode, drag a todo from one category to another, assert both source and target category orders and the moved todo's new category. 6. `tsc -b`, `vitest run`, `dotnet test`, `playwright test`, `docker build` (per user's `feedback_playwright_and_test_gates.md`).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
robert/todo#139
No description provided.