Files
kandev/apps/web/components/task/task-switcher-subtask-dnd.tsx
T
João Salavisa 35242b2982 feat(web): drag-reorder subtasks within a parent task (#962)
* feat(web): drag-reorder subtasks within a parent task

Adds per-parent subtask ordering in the sidebar. Drag a subtask under its
parent to reorder; the chosen order is independent of the global sort and
persists per browser via localStorage.

- New `sidebarTaskPrefs.subtaskOrderByParentId` slice field and matching
  `setSubtaskOrder(parentId, ids)` action, persisted to localStorage under
  `kandev.sidebar.subtaskOrderByParentId`.
- `applyView` applies the per-parent override after `separateSubtasks`, so
  it never touches root order or the active sort spec.
- TaskSwitcher exposes `onReorderSubtasks(parentId, ids)`; each parent's
  subtask list lives in its own nested DndContext / SortableContext using
  the existing dnd-kit setup.
- Task-deletion cleanup strips the deleted id as both parent key and
  subtask entry from `subtaskOrderByParentId` in store + localStorage.

* refactor(web): address PR review feedback on subtask reordering

- Extract `pruneSubtaskOrder(map, taskId)` helper in `local-storage.ts`;
  use it in both `cleanupTaskStorage` and `removeTaskFromSidebarPrefs`
  to keep the two cleanup paths in lockstep.
- Move `SortableSubtaskRow`, `SortableSubtaskList`, and `useSubtaskDnd`
  into a new sibling file `task-switcher-subtask-dnd.tsx` so the
  switcher stays under the 600-line soft limit.
- `SortableSubtaskList` now short-circuits to a plain row map when
  `onReorderSubtasks` is undefined — read-only callers no longer get a
  misleading grab cursor or unused DnD scaffolding.

* fix(web): add stable keys to non-DnD subtask render path

When `onReorderSubtasks` is undefined, the early-return branch in
`SortableSubtaskList` mapped subtasks without keys, falling back to
React's index-based reconciliation. Wrap each child in a keyed
`Fragment` so reorders or size changes reconcile correctly.

* refactor(web): single-source DRAG_ACTIVATION_DISTANCE

Export the constant from `task-switcher-subtask-dnd.tsx` and import it
in `task-switcher.tsx` instead of declaring the same value twice.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-21 01:31:42 +01:00

141 lines
3.8 KiB
TypeScript

"use client";
import { Fragment, useCallback, useMemo } from "react";
import {
DndContext,
PointerSensor,
closestCenter,
type DragEndEvent,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import type { TaskSwitcherItem } from "./task-switcher";
export const DRAG_ACTIVATION_DISTANCE = 8;
function SortableSubtaskRow({ taskId, children }: { taskId: string; children: React.ReactNode }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: taskId,
});
const sortableAttributes = {
...attributes,
role: undefined,
"aria-roledescription": undefined,
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
{...sortableAttributes}
{...listeners}
tabIndex={undefined}
data-testid="sortable-subtask-row"
data-task-id={taskId}
className={cn("cursor-grab active:cursor-grabbing", isDragging && "z-50")}
>
{children}
</div>
);
}
function useSubtaskDnd(
parentTaskId: string,
subtasks: TaskSwitcherItem[],
onReorderSubtasks: (parentTaskId: string, orderedSubtaskIds: string[]) => void,
) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE } }),
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = subtasks.map((t) => t.id);
const oldIndex = ids.indexOf(String(active.id));
const newIndex = ids.indexOf(String(over.id));
if (oldIndex < 0 || newIndex < 0) return;
onReorderSubtasks(parentTaskId, arrayMove(ids, oldIndex, newIndex));
},
[parentTaskId, subtasks, onReorderSubtasks],
);
const sortableIds = useMemo(() => subtasks.map((t) => t.id), [subtasks]);
return { sensors, handleDragEnd, sortableIds };
}
/**
* Per-parent sortable list. When `onReorderSubtasks` is omitted the DnD
* scaffolding (and the misleading grab cursor) is skipped — callers that
* don't reorder get a plain list with no wasted setup.
*/
export function SortableSubtaskList({
parentTaskId,
subtasks,
onReorderSubtasks,
renderRow,
}: {
parentTaskId: string;
subtasks: TaskSwitcherItem[];
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
renderRow: (sub: TaskSwitcherItem) => React.ReactNode;
}) {
if (!onReorderSubtasks) {
return (
<>
{subtasks.map((sub) => (
<Fragment key={sub.id}>{renderRow(sub)}</Fragment>
))}
</>
);
}
return (
<SortableSubtaskListInner
parentTaskId={parentTaskId}
subtasks={subtasks}
onReorderSubtasks={onReorderSubtasks}
renderRow={renderRow}
/>
);
}
function SortableSubtaskListInner({
parentTaskId,
subtasks,
onReorderSubtasks,
renderRow,
}: {
parentTaskId: string;
subtasks: TaskSwitcherItem[];
onReorderSubtasks: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
renderRow: (sub: TaskSwitcherItem) => React.ReactNode;
}) {
const { sensors, handleDragEnd, sortableIds } = useSubtaskDnd(
parentTaskId,
subtasks,
onReorderSubtasks,
);
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{subtasks.map((sub) => (
<SortableSubtaskRow key={sub.id} taskId={sub.id}>
{renderRow(sub)}
</SortableSubtaskRow>
))}
</SortableContext>
</DndContext>
);
}