Files
kandev/apps/web/components/task/task-switcher-subtask-dnd.tsx
T
João Salavisa fc341e6cff fix: restore Nest under candidates (#3727)
Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-09-16 17:41:30 +01:00

483 lines
16 KiB
TypeScript

"use client";
import { useCallback, useMemo, useState } from "react";
import {
DndContext,
MouseSensor,
TouchSensor,
closestCenter,
pointerWithin,
useDroppable,
type CollisionDetection,
type DragEndEvent,
type DragStartEvent,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { computeNestCandidates } from "@/lib/sidebar/nest-candidates";
import type { TaskSwitcherItem } from "./task-switcher";
export const DRAG_ACTIVATION_DISTANCE = 8;
export const TOUCH_DRAG_ACTIVATION_DELAY_MS = 250;
export const TOUCH_DRAG_ACTIVATION_TOLERANCE_PX = 5;
const TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS = {
pointer: { distance: DRAG_ACTIVATION_DISTANCE },
touch: {
delay: TOUCH_DRAG_ACTIVATION_DELAY_MS,
tolerance: TOUCH_DRAG_ACTIVATION_TOLERANCE_PX,
},
};
/** Shared mouse/touch activation constraints for the sidebar tree drags. */
export function taskSwitcherDragActivationConstraints() {
return TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS;
}
// Skip layout/paint for rows outside the sidebar scrollport so long task lists
// stay cheap to render and scroll. `auto 52px` seeds the height estimate before
// first paint; afterwards the browser remembers each row's real size, so
// scrollbar/anchor behavior stays stable and dnd-kit's drag-start rect
// measurements remain accurate for previously rendered rows.
const OFFSCREEN_SKIP = "[content-visibility:auto] [contain-intrinsic-block-size:auto_52px]";
// Nest drop zones are registered as `nest:<taskId>` droppables. A drop on one
// re-parents the dragged task under that task; any other drop keeps the
// existing sibling-reorder semantics.
export const NEST_DROPPABLE_PREFIX = "nest:";
/** Droppable id of a row's nest drop zone: `nest:<taskId>`. */
export function nestDroppableId(taskId: string): string {
return NEST_DROPPABLE_PREFIX + taskId;
}
/** Returns the task id when `id` is a nest droppable id, else null. */
export function parseNestDroppableId(id: string): string | null {
return id.startsWith(NEST_DROPPABLE_PREFIX) ? id.slice(NEST_DROPPABLE_PREFIX.length) : null;
}
export type SidebarDropDecision =
| { kind: "nest"; parentTaskId: string }
| { kind: "reorder-group"; orderedTaskIds: string[] }
| { kind: "reorder-subtasks"; parentTaskId: string; orderedTaskIds: string[] };
/**
* Maps a group-level drag end onto the single intended action.
*
* A drop on a `nest:` droppable re-parents the dragged task. Any other drop
* reorders only when both tasks share a sibling level (group root list or one
* parent's children); cross-level drops are a no-op, matching the previous
* per-level behavior where a drag could never leave its own level.
*/
export function resolveSidebarDrop(args: {
activeId: string;
overId: string | null;
groupRootIds: string[];
childrenByParent: Map<string, string[]>;
}): SidebarDropDecision | null {
const { activeId, overId, groupRootIds, childrenByParent } = args;
if (!overId || overId === activeId) return null;
const nestTarget = parseNestDroppableId(overId);
if (nestTarget !== null) {
if (nestTarget === activeId) return null;
return { kind: "nest", parentTaskId: nestTarget };
}
const levelOf = (taskId: string): string | null => {
if (groupRootIds.includes(taskId)) return GROUP_ROOT_LEVEL;
for (const [parentId, ids] of childrenByParent) {
if (ids.includes(taskId)) return parentId;
}
return null;
};
const activeLevel = levelOf(activeId);
const overLevel = levelOf(overId);
if (activeLevel === null || activeLevel !== overLevel) return null;
const levelIds =
activeLevel === GROUP_ROOT_LEVEL ? groupRootIds : (childrenByParent.get(activeLevel) ?? []);
const oldIndex = levelIds.indexOf(activeId);
const newIndex = levelIds.indexOf(overId);
if (oldIndex < 0 || newIndex < 0) return null;
const ordered = arrayMove(levelIds, oldIndex, newIndex);
if (activeLevel === GROUP_ROOT_LEVEL) {
return { kind: "reorder-group", orderedTaskIds: ordered };
}
return { kind: "reorder-subtasks", parentTaskId: activeLevel, orderedTaskIds: ordered };
}
// Sentinel level id for the group root list inside resolveSidebarDrop.
const GROUP_ROOT_LEVEL = "__group_root__";
/**
* Computes the rows a drag may nest the active task under, mirroring the
* context menu's candidate rules (computeNestCandidates) scoped to the
* active task's workflow. Every mode excludes the task, its current parent,
* and descendants; Kanban keeps the root-only depth limit while Office may
* target tasks at any depth.
*/
export function computeNestTargets(
activeTask: Pick<TaskSwitcherItem, "id" | "workflowId" | "parentTaskId"> | undefined,
groupTasks: TaskSwitcherItem[],
hierarchyTasks: readonly TaskSwitcherItem[] = groupTasks,
): Set<string> {
if (!activeTask) return new Set();
const sameWorkflow = groupTasks.filter((t) => t.workflowId === activeTask.workflowId);
return new Set(
computeNestCandidates(sameWorkflow, activeTask.id, hierarchyTasks).map((task) => task.id),
);
}
/**
* The drop target rendered on a row that the active drag may nest under.
* Absolute strip at the row's left edge; dropping on it re-parents the
* dragged task instead of reordering. Rendered only during an active drag
* with valid targets, so it never intercepts normal row clicks.
*/
export function NestDropZone({ taskId, title }: { taskId: string; title: string }) {
const { t } = useTranslation();
const { setNodeRef, isOver } = useDroppable({ id: nestDroppableId(taskId) });
const label = t("sidebar:nestUnder", { title });
return (
<div
ref={setNodeRef}
data-testid="nest-drop-zone"
data-task-id={taskId}
aria-label={label}
className={cn(
"absolute inset-y-0 left-0 z-20 w-6",
isOver ? "bg-primary/20" : "bg-transparent hover:bg-accent/40",
)}
>
{isOver && (
<span className="pointer-events-none absolute left-6 top-1/2 -translate-y-1/2 whitespace-nowrap rounded border bg-background px-2 py-0.5 text-xs shadow">
{label}
</span>
)}
</div>
);
}
/** Sortable implementation — isolated so `useSortable` is never called conditionally. */
function DraggableSortableTaskNode({
taskId,
depth,
handle,
nested,
}: {
taskId: string;
depth: number;
handle: React.ReactNode;
nested?: 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}
data-testid="sortable-task-block"
data-task-id={taskId}
data-depth={depth}
className={cn(OFFSCREEN_SKIP, isDragging && "z-50")}
>
<div
{...sortableAttributes}
{...listeners}
// Strip dnd-kit's default tabIndex={0}: only pointer-based sensors are
// wired, so keyboard tab stops here lead nowhere. If KeyboardSensor is
// added later, drop this override.
tabIndex={undefined}
data-testid="sortable-task-handle"
className="cursor-grab active:cursor-grabbing"
>
{handle}
</div>
{nested}
</div>
);
}
/**
* A single sortable node in the (arbitrarily deep) task tree.
*
* `setNodeRef` and the CSS transform live on the outer wrapper so the row and
* its nested subtree move together while dragging. Drag listeners attach ONLY
* to the `handle` (the task row) — `nested` (the child subtree) renders as a
* sibling outside the handle so a pointer-down on a descendant row drags that
* descendant, not this node. This is the same handle/children split the root
* level has always used; generalizing it is what makes nesting safe at depth.
*
* When `isDraggable` is false, renders a plain wrapper with no DnD wiring.
*/
export function SortableTaskNode({
taskId,
depth,
handle,
nested,
isDraggable = true,
}: {
taskId: string;
depth: number;
handle: React.ReactNode;
nested?: React.ReactNode;
isDraggable?: boolean;
}) {
if (!isDraggable) {
return (
<div
data-testid="sortable-task-block"
data-task-id={taskId}
data-depth={depth}
className={OFFSCREEN_SKIP}
>
<div data-testid="sortable-task-handle">{handle}</div>
{nested}
</div>
);
}
return (
<DraggableSortableTaskNode taskId={taskId} depth={depth} handle={handle} nested={nested} />
);
}
/**
* DnD wiring for one level of sibling tasks. Reordering is scoped to the
* siblings sharing a parent (cross-parent drag is intentionally unsupported).
*/
function useLevelDnd(tasks: TaskSwitcherItem[], onReorder?: (orderedTaskIds: string[]) => void) {
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS.pointer,
}),
useSensor(TouchSensor, {
activationConstraint: TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS.touch,
}),
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
if (!onReorder) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = tasks.map((t) => t.id);
const oldIndex = ids.indexOf(String(active.id));
const newIndex = ids.indexOf(String(over.id));
if (oldIndex < 0 || newIndex < 0) return;
onReorder(arrayMove(ids, oldIndex, newIndex));
},
[tasks, onReorder],
);
const sortableIds = useMemo(() => tasks.map((t) => t.id), [tasks]);
return { sensors, handleDragEnd, sortableIds };
}
/**
* Renders one level of sibling task nodes. When `onReorder` is omitted the DnD
* scaffolding (and the misleading grab cursor) is skipped, so non-reorderable
* callers get a plain list with no wasted setup.
*
* By default each level owns its own `DndContext`, which confines every drag
* to its sibling level. When `externalDragContext` is set the caller already
* rendered a group-spanning `DndContext` (see `TaskTreeDndGroup`) so cross-
* level drops like nest-under can be resolved — this level only contributes
* its `SortableContext` and nodes.
*/
export function SortableTaskLevel({
tasks,
onReorder,
renderNode,
externalDragContext = false,
}: {
tasks: TaskSwitcherItem[];
onReorder?: (orderedTaskIds: string[]) => void;
renderNode: (task: TaskSwitcherItem, isDraggable: boolean) => React.ReactNode;
externalDragContext?: boolean;
}) {
const { sensors, handleDragEnd, sortableIds } = useLevelDnd(tasks, onReorder);
const isDraggable = !!onReorder;
const body = tasks.map((task) => renderNode(task, isDraggable));
if (!onReorder) return <>{body}</>;
const sortable = (
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{body}
</SortableContext>
);
if (externalDragContext) return sortable;
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
{sortable}
</DndContext>
);
}
function useActiveNestDrag(
groupTasks: TaskSwitcherItem[],
tasksById: ReadonlyMap<string, TaskSwitcherItem>,
getNestHierarchyTasks?: () => TaskSwitcherItem[] | undefined,
) {
const [activeDrag, setActiveDrag] = useState<{
id: string;
hierarchyTasks?: TaskSwitcherItem[];
} | null>(null);
const activeDragId = activeDrag?.id ?? null;
const nestTargetIds = useMemo(
() =>
computeNestTargets(
activeDragId ? tasksById.get(activeDragId) : undefined,
groupTasks,
activeDrag?.hierarchyTasks ?? groupTasks,
),
[activeDrag, activeDragId, tasksById, groupTasks],
);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
setActiveDrag({
id: String(event.active.id),
hierarchyTasks: getNestHierarchyTasks?.(),
});
},
[getNestHierarchyTasks],
);
const clearActiveDrag = useCallback(() => setActiveDrag(null), []);
return { activeDragId, nestTargetIds, handleDragStart, clearActiveDrag };
}
type TaskTreeDndGroupProps = {
groupTasks: TaskSwitcherItem[];
getNestHierarchyTasks?: () => TaskSwitcherItem[] | undefined;
onReorderGroup?: (groupTaskIds: string[]) => void;
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
onNestTask?: (activeTaskId: string, parentTaskId: string) => void;
children: (nestTargetIds: Set<string>, activeDragId: string | null) => React.ReactNode;
};
/**
* One group-spanning DndContext for the sidebar task tree. Replaces the
* per-level contexts so a drag can target rows outside its own sibling level:
* reorder stays scoped to the level the drag started in (resolveSidebarDrop),
* and dropping on a `nest:` zone re-parents the dragged task.
*
* `children` receives the nest-target ids and the active drag id so rows can
* render their `NestDropZone` affordance only when they are valid targets.
*/
export function TaskTreeDndGroup({
groupTasks,
getNestHierarchyTasks,
onReorderGroup,
onReorderSubtasks,
onNestTask,
children,
}: TaskTreeDndGroupProps) {
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS.pointer,
}),
useSensor(TouchSensor, {
activationConstraint: TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS.touch,
}),
);
const enabled = !!onReorderGroup || !!onReorderSubtasks || !!onNestTask;
const { tasksById, groupRootIds, childrenByParent } = useMemo(() => {
const ids = new Set(groupTasks.map((t) => t.id));
const byParent = new Map<string, string[]>();
for (const t of groupTasks) {
if (t.parentTaskId && ids.has(t.parentTaskId)) {
const arr = byParent.get(t.parentTaskId) ?? [];
arr.push(t.id);
byParent.set(t.parentTaskId, arr);
}
}
return {
tasksById: new Map(groupTasks.map((t) => [t.id, t])),
groupRootIds: groupTasks
.filter((t) => !t.parentTaskId || !ids.has(t.parentTaskId))
.map((t) => t.id),
childrenByParent: byParent,
};
}, [groupTasks]);
const { activeDragId, nestTargetIds, handleDragStart, clearActiveDrag } = useActiveNestDrag(
groupTasks,
tasksById,
getNestHierarchyTasks,
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
clearActiveDrag();
const { active, over } = event;
if (!over) return;
const decision = resolveSidebarDrop({
activeId: String(active.id),
overId: String(over.id),
groupRootIds,
childrenByParent,
});
if (!decision) return;
switch (decision.kind) {
case "nest":
onNestTask?.(String(active.id), decision.parentTaskId);
break;
case "reorder-group":
onReorderGroup?.(decision.orderedTaskIds);
break;
case "reorder-subtasks":
onReorderSubtasks?.(decision.parentTaskId, decision.orderedTaskIds);
break;
}
},
[
clearActiveDrag,
groupRootIds,
childrenByParent,
onReorderGroup,
onReorderSubtasks,
onNestTask,
],
);
const collisionDetection = useCallback<CollisionDetection>((args) => {
// A pointer resting on a nest zone always means "nest under this row";
// everywhere else falls back to the nearest-row reorder.
const within = pointerWithin(args);
const nest = within.filter((collision) =>
collision.id.toString().startsWith(NEST_DROPPABLE_PREFIX),
);
if (nest.length > 0) return nest;
return closestCenter(args);
}, []);
if (!enabled) return <>{children(new Set(), null)}</>;
return (
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={clearActiveDrag}
>
{children(nestTargetIds, activeDragId)}
</DndContext>
);
}