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>
This commit is contained in:
@@ -42,11 +42,22 @@ function MobileTaskList({
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const view = useEffectiveSidebarView();
|
||||
const { pinnedTaskIds, orderedTaskIds, togglePinnedTask, handleReorderGroup } =
|
||||
useSidebarTaskPrefs();
|
||||
const {
|
||||
pinnedTaskIds,
|
||||
orderedTaskIds,
|
||||
subtaskOrderByParentId,
|
||||
togglePinnedTask,
|
||||
handleReorderGroup,
|
||||
handleReorderSubtasks,
|
||||
} = useSidebarTaskPrefs();
|
||||
const grouped = useMemo(
|
||||
() => applyView(tasks, view, { pinnedTaskIds, orderedTaskIds }),
|
||||
[tasks, view, pinnedTaskIds, orderedTaskIds],
|
||||
() =>
|
||||
applyView(tasks, view, {
|
||||
pinnedTaskIds,
|
||||
orderedTaskIds,
|
||||
subtaskOrderByParentId,
|
||||
}),
|
||||
[tasks, view, pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId],
|
||||
);
|
||||
return (
|
||||
<TaskSwitcher
|
||||
@@ -58,6 +69,7 @@ function MobileTaskList({
|
||||
onDeleteTask={onDeleteTask}
|
||||
onTogglePin={togglePinnedTask}
|
||||
onReorderGroup={handleReorderGroup}
|
||||
onReorderSubtasks={handleReorderSubtasks}
|
||||
pinnedTaskIds={pinnedTaskIds}
|
||||
deletingTaskId={deletingTaskId}
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import type { KanbanState } from "@/lib/state/slices";
|
||||
import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
|
||||
import { TaskSwitcher } from "./task-switcher";
|
||||
import { TaskSwitcher, type TaskSwitcherItem } from "./task-switcher";
|
||||
import { applyView } from "@/lib/sidebar/apply-view";
|
||||
import { SidebarFilterBar } from "./sidebar-filter/sidebar-filter-bar";
|
||||
import { MOCK_ITEMS, MOCK_SIDEBAR } from "./sidebar-mock-data";
|
||||
@@ -575,6 +575,22 @@ function useBulkGitStatusSubscription(primarySessionIds: string[]) {
|
||||
}, [primarySessionIds, connectionStatus, activeSessionId]);
|
||||
}
|
||||
|
||||
function useGroupedSidebarView(displayTasks: TaskSwitcherItem[]) {
|
||||
const prefs = useSidebarTaskPrefs();
|
||||
const effectiveView = useEffectiveSidebarView();
|
||||
const { pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId } = prefs;
|
||||
const grouped = useMemo(
|
||||
() =>
|
||||
applyView(displayTasks, effectiveView, {
|
||||
pinnedTaskIds,
|
||||
orderedTaskIds,
|
||||
subtaskOrderByParentId,
|
||||
}),
|
||||
[displayTasks, effectiveView, pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId],
|
||||
);
|
||||
return { grouped, effectiveView, prefs };
|
||||
}
|
||||
|
||||
export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
workspaceId,
|
||||
}: TaskSessionSidebarProps) {
|
||||
@@ -618,13 +634,8 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
const toggleSidebarGroupCollapsed = useAppStore((state) => state.toggleSidebarGroupCollapsed);
|
||||
const collapsedSubtaskParents = useAppStore((state) => state.collapsedSubtaskParents);
|
||||
const toggleSubtaskCollapsed = useAppStore((state) => state.toggleSubtaskCollapsed);
|
||||
const { pinnedTaskIds, orderedTaskIds, togglePinnedTask, handleReorderGroup } =
|
||||
useSidebarTaskPrefs();
|
||||
const effectiveView = useEffectiveSidebarView();
|
||||
const grouped = useMemo(
|
||||
() => applyView(displayTasks, effectiveView, { pinnedTaskIds, orderedTaskIds }),
|
||||
[displayTasks, effectiveView, pinnedTaskIds, orderedTaskIds],
|
||||
);
|
||||
const { grouped, effectiveView, prefs } = useGroupedSidebarView(displayTasks);
|
||||
const { pinnedTaskIds, togglePinnedTask, handleReorderGroup, handleReorderSubtasks } = prefs;
|
||||
return (
|
||||
<PanelRoot data-testid="task-sidebar">
|
||||
<SidebarFilterBar />
|
||||
@@ -646,6 +657,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
onMoveToStep={handleMoveToStep}
|
||||
onTogglePin={togglePinnedTask}
|
||||
onReorderGroup={handleReorderGroup}
|
||||
onReorderSubtasks={handleReorderSubtasks}
|
||||
pinnedTaskIds={pinnedTaskIds}
|
||||
deletingTaskId={deletingTaskId}
|
||||
isLoading={isLoadingWorkflow}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -23,8 +23,7 @@ import { TaskItem } from "./task-item";
|
||||
import { TaskItemWithContextMenu, type StepDef } from "./task-switcher-context-menu";
|
||||
import type { GroupedSidebarList, SidebarGroup } from "@/lib/sidebar/apply-view";
|
||||
import { type TaskMoveWorkflow } from "@/components/task/task-move-context-menu";
|
||||
|
||||
const DRAG_ACTIVATION_DISTANCE = 8;
|
||||
import { DRAG_ACTIVATION_DISTANCE, SortableSubtaskList } from "./task-switcher-subtask-dnd";
|
||||
|
||||
export type TaskSwitcherItem = {
|
||||
id: string;
|
||||
@@ -73,6 +72,7 @@ type TaskSwitcherProps = {
|
||||
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
|
||||
onTogglePin?: (taskId: string) => void;
|
||||
onReorderGroup?: (groupTaskIds: string[]) => void;
|
||||
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
|
||||
pinnedTaskIds?: string[];
|
||||
deletingTaskId?: string | null;
|
||||
isLoading?: boolean;
|
||||
@@ -283,6 +283,7 @@ type GroupSectionProps = {
|
||||
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
|
||||
onTogglePin?: (taskId: string) => void;
|
||||
onReorderGroup?: (groupTaskIds: string[]) => void;
|
||||
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
|
||||
pinnedSet: Set<string>;
|
||||
deletingTaskId?: string | null;
|
||||
};
|
||||
@@ -318,6 +319,7 @@ function GroupTaskList({
|
||||
onToggleSubtasks,
|
||||
pinnedSet,
|
||||
rowProps,
|
||||
onReorderSubtasks,
|
||||
}: {
|
||||
group: SidebarGroup;
|
||||
subTasksByParentId: Map<string, TaskSwitcherItem[]>;
|
||||
@@ -325,6 +327,7 @@ function GroupTaskList({
|
||||
onToggleSubtasks?: (parentTaskId: string) => void;
|
||||
pinnedSet: Set<string>;
|
||||
rowProps: Omit<TaskRowProps, "task" | "subtaskToggle" | "isPinned" | "isSubTask">;
|
||||
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -353,14 +356,19 @@ function GroupTaskList({
|
||||
/>
|
||||
}
|
||||
subTasks={
|
||||
!subsHidden &&
|
||||
subs?.map((sub) => (
|
||||
// Subtasks aren't independently sortable or pinnable — pinning
|
||||
// would show an icon but `floatPinnedToTop` only operates on
|
||||
// root tasks, so the row wouldn't move. Drop both props to
|
||||
// avoid the misleading no-op menu item.
|
||||
<TaskRow key={sub.id} task={sub} isSubTask {...rowProps} onTogglePin={undefined} />
|
||||
))
|
||||
!subsHidden && subs && subs.length > 0 ? (
|
||||
<SortableSubtaskList
|
||||
parentTaskId={task.id}
|
||||
subtasks={subs}
|
||||
onReorderSubtasks={onReorderSubtasks}
|
||||
// Subtasks aren't pinnable — pinning would show an icon but
|
||||
// `floatPinnedToTop` only operates on root tasks, so the row
|
||||
// wouldn't move. Drop the prop to avoid the no-op menu item.
|
||||
renderRow={(sub) => (
|
||||
<TaskRow task={sub} isSubTask {...rowProps} onTogglePin={undefined} />
|
||||
)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -388,6 +396,7 @@ function GroupSection({
|
||||
onMoveToStep,
|
||||
onTogglePin,
|
||||
onReorderGroup,
|
||||
onReorderSubtasks,
|
||||
pinnedSet,
|
||||
deletingTaskId,
|
||||
}: GroupSectionProps) {
|
||||
@@ -432,6 +441,7 @@ function GroupSection({
|
||||
onToggleSubtasks={onToggleSubtasks}
|
||||
pinnedSet={pinnedSet}
|
||||
rowProps={rowProps}
|
||||
onReorderSubtasks={onReorderSubtasks}
|
||||
/>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
@@ -457,6 +467,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({
|
||||
onMoveToStep,
|
||||
onTogglePin,
|
||||
onReorderGroup,
|
||||
onReorderSubtasks,
|
||||
pinnedTaskIds,
|
||||
deletingTaskId,
|
||||
isLoading = false,
|
||||
@@ -497,6 +508,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({
|
||||
onMoveToStep={onMoveToStep}
|
||||
onTogglePin={onTogglePin}
|
||||
onReorderGroup={onReorderGroup}
|
||||
onReorderSubtasks={onReorderSubtasks}
|
||||
pinnedSet={pinnedSet}
|
||||
deletingTaskId={deletingTaskId}
|
||||
/>
|
||||
|
||||
@@ -9,13 +9,18 @@ import { mergeGroupOrder } from "@/lib/sidebar/apply-view";
|
||||
* "Custom" via the view draft — read draft-first so subsequent drags don't
|
||||
* try to re-set custom on top of an already-custom draft. Both desktop and
|
||||
* mobile sidebars use this hook to stay in sync.
|
||||
*
|
||||
* `handleReorderSubtasks` is intentionally scoped to one parent: it writes to
|
||||
* `subtaskOrderByParentId` only, so the root-task sort is never disturbed.
|
||||
*/
|
||||
export function useSidebarTaskPrefs() {
|
||||
const store = useAppStoreApi();
|
||||
const pinnedTaskIds = useAppStore((s) => s.sidebarTaskPrefs.pinnedTaskIds);
|
||||
const orderedTaskIds = useAppStore((s) => s.sidebarTaskPrefs.orderedTaskIds);
|
||||
const subtaskOrderByParentId = useAppStore((s) => s.sidebarTaskPrefs.subtaskOrderByParentId);
|
||||
const togglePinnedTask = useAppStore((s) => s.togglePinnedTask);
|
||||
const setSidebarTaskOrder = useAppStore((s) => s.setSidebarTaskOrder);
|
||||
const setSubtaskOrder = useAppStore((s) => s.setSubtaskOrder);
|
||||
const updateSidebarDraft = useAppStore((s) => s.updateSidebarDraft);
|
||||
|
||||
const handleReorderGroup = useCallback(
|
||||
@@ -33,5 +38,19 @@ export function useSidebarTaskPrefs() {
|
||||
[store, setSidebarTaskOrder, updateSidebarDraft],
|
||||
);
|
||||
|
||||
return { pinnedTaskIds, orderedTaskIds, togglePinnedTask, handleReorderGroup };
|
||||
const handleReorderSubtasks = useCallback(
|
||||
(parentTaskId: string, orderedSubtaskIds: string[]) => {
|
||||
setSubtaskOrder(parentTaskId, orderedSubtaskIds);
|
||||
},
|
||||
[setSubtaskOrder],
|
||||
);
|
||||
|
||||
return {
|
||||
pinnedTaskIds,
|
||||
orderedTaskIds,
|
||||
subtaskOrderByParentId,
|
||||
togglePinnedTask,
|
||||
handleReorderGroup,
|
||||
handleReorderSubtasks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -580,6 +580,11 @@ export function cleanupTaskStorage(
|
||||
setStoredOrderedTaskIds(ordered.filter((id) => id !== taskId));
|
||||
}
|
||||
|
||||
// Per-parent subtask order: drop the deleted task as a parent key, and strip
|
||||
// it from any other parent's subtask-order list (in case it was a subtask).
|
||||
const subOrder = getStoredSubtaskOrderByParentId();
|
||||
if (pruneSubtaskOrder(subOrder, taskId)) setStoredSubtaskOrderByParentId(subOrder);
|
||||
|
||||
// Env-keyed storage — dockview layout + maximize live under task envs.
|
||||
for (const envId of envIds) {
|
||||
removeEnvMaximizeState(envId);
|
||||
@@ -697,6 +702,48 @@ export function setStoredOrderedTaskIds(ids: string[]): void {
|
||||
setLocalStorage(SIDEBAR_TASK_ORDER_KEY, ids);
|
||||
}
|
||||
|
||||
const SIDEBAR_SUBTASK_ORDER_KEY = "kandev.sidebar.subtaskOrderByParentId";
|
||||
|
||||
export function getStoredSubtaskOrderByParentId(): Record<string, string[]> {
|
||||
const raw = getLocalStorage<Record<string, string[]>>(SIDEBAR_SUBTASK_ORDER_KEY, {}) as unknown;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const out: Record<string, string[]> = {};
|
||||
for (const [parentId, ids] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (typeof parentId !== "string" || !Array.isArray(ids)) continue;
|
||||
const filtered = ids.filter((id): id is string => typeof id === "string");
|
||||
if (filtered.length > 0) out[parentId] = filtered;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function setStoredSubtaskOrderByParentId(map: Record<string, string[]>): void {
|
||||
setLocalStorage(SIDEBAR_SUBTASK_ORDER_KEY, map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a task id from a subtask-order map: drop it as a parent key, and
|
||||
* remove it from every other parent's subtask list (cleaning up the parent
|
||||
* entry if its list becomes empty). Mutates `map` in place and returns
|
||||
* `true` if anything changed. Used by both `cleanupTaskStorage` (plain
|
||||
* object) and `removeTaskFromSidebarPrefs` (Immer draft) to keep the two
|
||||
* cleanup paths in lockstep.
|
||||
*/
|
||||
export function pruneSubtaskOrder(map: Record<string, string[]>, taskId: string): boolean {
|
||||
let changed = false;
|
||||
if (taskId in map) {
|
||||
delete map[taskId];
|
||||
changed = true;
|
||||
}
|
||||
for (const [parentId, ids] of Object.entries(map)) {
|
||||
if (!ids.includes(taskId)) continue;
|
||||
const next = ids.filter((id) => id !== taskId);
|
||||
if (next.length === 0) delete map[parentId];
|
||||
else map[parentId] = next;
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// --- Sidebar collapsed subtask parents (sessionStorage, tab-scoped) ---
|
||||
|
||||
const COLLAPSED_SUBTASKS_KEY = "kandev.sidebar.collapsedSubtasks";
|
||||
|
||||
@@ -455,17 +455,16 @@ describe("applyView — custom sort", () => {
|
||||
task({ id: "c1", title: "C1", parentTaskId: "p1", createdAt: "2026-02-01" }),
|
||||
task({ id: "c2", title: "C2", parentTaskId: "p1", createdAt: "2026-02-02" }),
|
||||
];
|
||||
// Simulates a drag of P3 to the front: handleReorderGroup writes only
|
||||
// root IDs into orderedTaskIds.
|
||||
// Simulates a drag of P3 to the front. orderedTaskIds contains only root
|
||||
// IDs here; subtasks fall back to "newest createdAt first".
|
||||
const out = applyView(tasks, customView, {
|
||||
pinnedTaskIds: [],
|
||||
orderedTaskIds: ["p3", "p1", "p2"],
|
||||
});
|
||||
// Root order matches the drag.
|
||||
expect(out.groups[0].tasks.map((t) => t.id)).toEqual(["p3", "p1", "p2"]);
|
||||
// Subtasks stay attached to p1 — they're never in orderedTaskIds and they
|
||||
// never appear in group.tasks, so dragging the parent doesn't separate
|
||||
// them or reorder them relative to each other.
|
||||
// Subtasks stay attached to p1 and use the createdAt fallback (newest
|
||||
// first) since neither is in orderedTaskIds.
|
||||
expect(out.subTasksByParentId.get("p1")?.map((t) => t.id)).toEqual(["c2", "c1"]);
|
||||
expect(out.subTasksByParentId.get("p2")).toBeUndefined();
|
||||
expect(out.subTasksByParentId.get("p3")).toBeUndefined();
|
||||
@@ -486,6 +485,74 @@ describe("applyView — custom sort", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyView — subtaskOrderByParentId", () => {
|
||||
// Subtask ordering is independent of the view's sort: the global sort still
|
||||
// determines root order, and per-parent overrides reorder *only* that parent's
|
||||
// subtask list.
|
||||
const titleAscView: SidebarView = {
|
||||
id: "v",
|
||||
name: "v",
|
||||
filters: [],
|
||||
sort: { key: "title", direction: "asc" },
|
||||
group: "none",
|
||||
collapsedGroups: [],
|
||||
};
|
||||
const parentId = "p1";
|
||||
const sub = (id: string, parent: string) => task({ id, title: id, parentTaskId: parent });
|
||||
|
||||
it("reorders one parent's subtasks without flipping the root sort", () => {
|
||||
const tasks = [
|
||||
task({ id: "p1", title: "P1" }),
|
||||
task({ id: "p2", title: "P2" }),
|
||||
sub("c1", parentId),
|
||||
sub("c2", parentId),
|
||||
sub("c3", parentId),
|
||||
];
|
||||
const out = applyView(tasks, titleAscView, {
|
||||
pinnedTaskIds: [],
|
||||
orderedTaskIds: [],
|
||||
subtaskOrderByParentId: { [parentId]: ["c1", "c3", "c2"] },
|
||||
});
|
||||
// Root order still follows title asc — untouched by the subtask reorder.
|
||||
expect(out.groups[0].tasks.map((t) => t.id)).toEqual(["p1", "p2"]);
|
||||
expect(out.subTasksByParentId.get(parentId)?.map((t) => t.id)).toEqual(["c1", "c3", "c2"]);
|
||||
});
|
||||
|
||||
it("subtasks not in the override keep the active sort's order, after the listed ones", () => {
|
||||
const tasks = [
|
||||
task({ id: "p1", title: "P1" }),
|
||||
sub("c1", parentId),
|
||||
sub("c2", parentId),
|
||||
sub("c3", parentId),
|
||||
];
|
||||
// Title-asc fallback over the unordered subtasks: c1, c2 follow after c3.
|
||||
const out = applyView(tasks, titleAscView, {
|
||||
pinnedTaskIds: [],
|
||||
orderedTaskIds: [],
|
||||
subtaskOrderByParentId: { [parentId]: ["c3"] },
|
||||
});
|
||||
expect(out.subTasksByParentId.get(parentId)?.map((t) => t.id)).toEqual(["c3", "c1", "c2"]);
|
||||
});
|
||||
|
||||
it("override for parent A does not affect parent B's subtasks", () => {
|
||||
const tasks = [
|
||||
task({ id: "p1", title: "P1" }),
|
||||
task({ id: "p2", title: "P2" }),
|
||||
sub("a1", "p1"),
|
||||
sub("a2", "p1"),
|
||||
sub("b1", "p2"),
|
||||
sub("b2", "p2"),
|
||||
];
|
||||
const out = applyView(tasks, titleAscView, {
|
||||
pinnedTaskIds: [],
|
||||
orderedTaskIds: [],
|
||||
subtaskOrderByParentId: { p1: ["a2", "a1"] },
|
||||
});
|
||||
expect(out.subTasksByParentId.get("p1")?.map((t) => t.id)).toEqual(["a2", "a1"]);
|
||||
expect(out.subTasksByParentId.get("p2")?.map((t) => t.id)).toEqual(["b1", "b2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeGroupOrder", () => {
|
||||
it("appends when no group items are in current", () => {
|
||||
expect(mergeGroupOrder([], ["a", "b"])).toEqual(["a", "b"]);
|
||||
|
||||
@@ -26,6 +26,7 @@ export type GroupedSidebarList = {
|
||||
export type SidebarTaskPrefs = {
|
||||
pinnedTaskIds: string[];
|
||||
orderedTaskIds: string[];
|
||||
subtaskOrderByParentId?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
type DimensionExtractor = (task: TaskSwitcherItem) => FilterValue | undefined;
|
||||
@@ -310,6 +311,28 @@ function buildIndex(ids: string[]): Map<string, number> {
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort subtasks of a single parent by the user's manual order. Listed
|
||||
* subtasks come first in their stored order; unlisted ones keep their incoming
|
||||
* order (which reflects the active sort) afterwards.
|
||||
*/
|
||||
function applySubtaskOrder(
|
||||
subtasks: TaskSwitcherItem[],
|
||||
orderedSubtaskIds: string[],
|
||||
): TaskSwitcherItem[] {
|
||||
const orderIndex = buildIndex(orderedSubtaskIds);
|
||||
const withSortIndex = subtasks.map((t, i) => ({ t, sortIdx: i }));
|
||||
withSortIndex.sort((a, b) => {
|
||||
const ai = orderIndex.get(a.t.id);
|
||||
const bi = orderIndex.get(b.t.id);
|
||||
if (ai !== undefined && bi !== undefined) return ai - bi;
|
||||
if (ai !== undefined) return -1;
|
||||
if (bi !== undefined) return 1;
|
||||
return a.sortIdx - b.sortIdx;
|
||||
});
|
||||
return withSortIndex.map((x) => x.t);
|
||||
}
|
||||
|
||||
export function applyView(
|
||||
tasks: TaskSwitcherItem[],
|
||||
view: SidebarView,
|
||||
@@ -318,6 +341,14 @@ export function applyView(
|
||||
const filtered = applyFilters(tasks, view.filters);
|
||||
const sorted = applySort(filtered, view.sort, prefs?.orderedTaskIds);
|
||||
const grouped = applyGroup(sorted, view.group);
|
||||
const subOrderMap = prefs?.subtaskOrderByParentId;
|
||||
if (subOrderMap) {
|
||||
for (const [parentId, orderedIds] of Object.entries(subOrderMap)) {
|
||||
const subs = grouped.subTasksByParentId.get(parentId);
|
||||
if (!subs) continue;
|
||||
grouped.subTasksByParentId.set(parentId, applySubtaskOrder(subs, orderedIds));
|
||||
}
|
||||
}
|
||||
if (!prefs || prefs.pinnedTaskIds.length === 0) return grouped;
|
||||
const pinnedSet = new Set(prefs.pinnedTaskIds);
|
||||
const pinIndex = buildIndex(prefs.pinnedTaskIds);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { setStoredOrderedTaskIds, setStoredPinnedTaskIds } from "@/lib/local-storage";
|
||||
import {
|
||||
pruneSubtaskOrder,
|
||||
setStoredOrderedTaskIds,
|
||||
setStoredPinnedTaskIds,
|
||||
setStoredSubtaskOrderByParentId,
|
||||
} from "@/lib/local-storage";
|
||||
import type { UISlice } from "./types";
|
||||
|
||||
type ImmerSet = (recipe: (draft: UISlice) => void, shouldReplace?: false | undefined) => void;
|
||||
@@ -18,6 +23,13 @@ export function buildSidebarTaskPrefsActions(set: ImmerSet) {
|
||||
draft.sidebarTaskPrefs.orderedTaskIds = orderedTaskIds;
|
||||
setStoredOrderedTaskIds(orderedTaskIds);
|
||||
}),
|
||||
setSubtaskOrder: (parentTaskId: string, orderedSubtaskIds: string[]) =>
|
||||
set((draft) => {
|
||||
const map = draft.sidebarTaskPrefs.subtaskOrderByParentId;
|
||||
if (orderedSubtaskIds.length === 0) delete map[parentTaskId];
|
||||
else map[parentTaskId] = orderedSubtaskIds;
|
||||
setStoredSubtaskOrderByParentId(map);
|
||||
}),
|
||||
removeTaskFromSidebarPrefs: (taskId: string) =>
|
||||
set((draft) => {
|
||||
const prefs = draft.sidebarTaskPrefs;
|
||||
@@ -31,6 +43,9 @@ export function buildSidebarTaskPrefsActions(set: ImmerSet) {
|
||||
prefs.orderedTaskIds.splice(orderIdx, 1);
|
||||
setStoredOrderedTaskIds(prefs.orderedTaskIds);
|
||||
}
|
||||
if (pruneSubtaskOrder(prefs.subtaskOrderByParentId, taskId)) {
|
||||
setStoredSubtaskOrderByParentId(prefs.subtaskOrderByParentId);
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,6 +106,12 @@ export type SidebarTaskPrefsState = {
|
||||
pinnedTaskIds: string[];
|
||||
/** Manual order. Tasks not present fall back to the active sort. */
|
||||
orderedTaskIds: string[];
|
||||
/**
|
||||
* Per-parent subtask order. Keyed by parent task id; value is the ordered
|
||||
* subtask ids. Subtasks not listed fall back to the active sort.
|
||||
* Independent of the global `orderedTaskIds` and the view's sort spec.
|
||||
*/
|
||||
subtaskOrderByParentId: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type UISliceState = {
|
||||
@@ -183,10 +189,13 @@ export type UISliceActions = {
|
||||
setKanbanPreviewedTaskId: (taskId: string | null) => void;
|
||||
togglePinnedTask: (taskId: string) => void;
|
||||
setSidebarTaskOrder: (orderedTaskIds: string[]) => void;
|
||||
/** Replace the stored subtask order for a parent task. Empty array clears it. */
|
||||
setSubtaskOrder: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
|
||||
/**
|
||||
* Drop a task ID from both pinned and ordered arrays in-memory and persist.
|
||||
* Called on task deletion so the in-memory state doesn't out-of-date the
|
||||
* already-cleaned localStorage and silently re-write the deleted ID back.
|
||||
* Drop a task ID from pinned, ordered, and subtask-order arrays in-memory
|
||||
* and persist. Called on task deletion so the in-memory state doesn't
|
||||
* out-of-date the already-cleaned localStorage and silently re-write the
|
||||
* deleted ID back.
|
||||
*/
|
||||
removeTaskFromSidebarPrefs: (taskId: string) => void;
|
||||
};
|
||||
|
||||
@@ -143,6 +143,76 @@ describe("sidebar task prefs (pin + manual order)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSubtaskOrder", () => {
|
||||
const SUB_KEY = "kandev.sidebar.subtaskOrderByParentId";
|
||||
const PARENT_A = "parent-a";
|
||||
const PARENT_B = "parent-b";
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("hydrates subtaskOrderByParentId from localStorage", () => {
|
||||
window.localStorage.setItem(SUB_KEY, JSON.stringify({ [PARENT_A]: ["c1", "c2"] }));
|
||||
const store = makeStore();
|
||||
expect(store.getState().sidebarTaskPrefs.subtaskOrderByParentId).toEqual({
|
||||
[PARENT_A]: ["c1", "c2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("setSubtaskOrder writes per-parent order and persists", () => {
|
||||
const store = makeStore();
|
||||
store.getState().setSubtaskOrder(PARENT_A, ["c2", "c1"]);
|
||||
expect(store.getState().sidebarTaskPrefs.subtaskOrderByParentId).toEqual({
|
||||
[PARENT_A]: ["c2", "c1"],
|
||||
});
|
||||
expect(JSON.parse(window.localStorage.getItem(SUB_KEY) ?? "null")).toEqual({
|
||||
[PARENT_A]: ["c2", "c1"],
|
||||
});
|
||||
|
||||
store.getState().setSubtaskOrder(PARENT_B, ["d1"]);
|
||||
expect(store.getState().sidebarTaskPrefs.subtaskOrderByParentId).toEqual({
|
||||
[PARENT_A]: ["c2", "c1"],
|
||||
[PARENT_B]: ["d1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("setSubtaskOrder with empty array clears the parent entry", () => {
|
||||
const store = makeStore();
|
||||
store.getState().setSubtaskOrder(PARENT_A, ["c1"]);
|
||||
store.getState().setSubtaskOrder(PARENT_A, []);
|
||||
expect(store.getState().sidebarTaskPrefs.subtaskOrderByParentId).toEqual({});
|
||||
expect(JSON.parse(window.localStorage.getItem(SUB_KEY) ?? "null")).toEqual({});
|
||||
});
|
||||
|
||||
it("removeTaskFromSidebarPrefs drops the task as a parent key and from sibling lists", () => {
|
||||
const store = makeStore();
|
||||
store.getState().setSubtaskOrder(PARENT_A, ["c1", "c2"]);
|
||||
store.getState().setSubtaskOrder(PARENT_B, ["c1", "d1"]);
|
||||
|
||||
// Removing c1 — it's both a subtask under p1 and p2.
|
||||
store.getState().removeTaskFromSidebarPrefs("c1");
|
||||
expect(store.getState().sidebarTaskPrefs.subtaskOrderByParentId).toEqual({
|
||||
[PARENT_A]: ["c2"],
|
||||
[PARENT_B]: ["d1"],
|
||||
});
|
||||
|
||||
// Removing the parent itself wipes its entry.
|
||||
store.getState().removeTaskFromSidebarPrefs(PARENT_A);
|
||||
expect(store.getState().sidebarTaskPrefs.subtaskOrderByParentId).toEqual({
|
||||
[PARENT_B]: ["d1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("removeTaskFromSidebarPrefs drops the parent key if removing its last subtask", () => {
|
||||
const store = makeStore();
|
||||
store.getState().setSubtaskOrder(PARENT_A, ["c1"]);
|
||||
store.getState().removeTaskFromSidebarPrefs("c1");
|
||||
expect(store.getState().sidebarTaskPrefs.subtaskOrderByParentId).toEqual({});
|
||||
expect(JSON.parse(window.localStorage.getItem(SUB_KEY) ?? "null")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderSidebarViews", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getStoredSidebarActiveViewId,
|
||||
getStoredSidebarDraft,
|
||||
getStoredSidebarUserViews,
|
||||
getStoredSubtaskOrderByParentId,
|
||||
removeStoredSidebarDraft,
|
||||
setLocalStorage,
|
||||
setStoredCollapsedSubtaskParents,
|
||||
@@ -120,7 +121,7 @@ export const defaultUIState: UISliceState = {
|
||||
sidebarViews: loadSidebarState(),
|
||||
collapsedSubtaskParents: [],
|
||||
kanbanPreviewedTaskId: null,
|
||||
sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [] },
|
||||
sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [], subtaskOrderByParentId: {} },
|
||||
};
|
||||
|
||||
type ImmerSet = Parameters<typeof createUISlice>[0];
|
||||
@@ -524,6 +525,7 @@ export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], []
|
||||
sidebarTaskPrefs: {
|
||||
pinnedTaskIds: getStoredPinnedTaskIds(),
|
||||
orderedTaskIds: getStoredOrderedTaskIds(),
|
||||
subtaskOrderByParentId: getStoredSubtaskOrderByParentId(),
|
||||
},
|
||||
...buildPreviewActions(set),
|
||||
...buildMobileActions(set),
|
||||
|
||||
@@ -478,6 +478,7 @@ export type AppState = {
|
||||
setKanbanPreviewedTaskId: UIA["setKanbanPreviewedTaskId"];
|
||||
togglePinnedTask: UIA["togglePinnedTask"];
|
||||
setSidebarTaskOrder: UIA["setSidebarTaskOrder"];
|
||||
setSubtaskOrder: UIA["setSubtaskOrder"];
|
||||
removeTaskFromSidebarPrefs: UIA["removeTaskFromSidebarPrefs"];
|
||||
// Office actions
|
||||
setOfficeAgentProfiles: (agents: AgentProfile[]) => void;
|
||||
|
||||
Reference in New Issue
Block a user