35242b2982
* 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>
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { create } from "zustand";
|
|
import { immer } from "zustand/middleware/immer";
|
|
import { updateUserSettings } from "@/lib/api/domains/settings-api";
|
|
import { createUISlice } from "./ui-slice";
|
|
import type { SidebarViewDraft } from "./sidebar-view-types";
|
|
import type { UISlice } from "./types";
|
|
|
|
vi.mock("@/lib/api/domains/settings-api", () => ({
|
|
updateUserSettings: vi.fn(() => Promise.resolve({ settings: {} })),
|
|
}));
|
|
|
|
function makeStore() {
|
|
return create<UISlice>()(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
immer((...a) => ({ ...(createUISlice as any)(...a) })),
|
|
);
|
|
}
|
|
|
|
const KEY = "kandev.sidebar.collapsedSubtasks";
|
|
const TASK_A = "task-a";
|
|
const TASK_B = "task-b";
|
|
const SIDEBAR_VIEWS_KEY = "kandev.sidebar.views";
|
|
|
|
function makeSidebarView(id: string, name: string) {
|
|
return {
|
|
id,
|
|
name,
|
|
filters: [],
|
|
sort: { key: "state" as const, direction: "asc" as const },
|
|
group: "none" as const,
|
|
collapsedGroups: [],
|
|
};
|
|
}
|
|
|
|
describe("toggleSubtaskCollapsed", () => {
|
|
beforeEach(() => {
|
|
window.sessionStorage.clear();
|
|
});
|
|
|
|
it("hydrates initial state from sessionStorage", () => {
|
|
window.sessionStorage.setItem(KEY, JSON.stringify(["task-hydrated"]));
|
|
const store = makeStore();
|
|
expect(store.getState().collapsedSubtaskParents).toEqual(["task-hydrated"]);
|
|
});
|
|
|
|
it("adds a parent id on first toggle and persists it", () => {
|
|
const store = makeStore();
|
|
store.getState().toggleSubtaskCollapsed(TASK_A);
|
|
|
|
expect(store.getState().collapsedSubtaskParents).toEqual([TASK_A]);
|
|
expect(JSON.parse(window.sessionStorage.getItem(KEY) ?? "null")).toEqual([TASK_A]);
|
|
});
|
|
|
|
it("removes a parent id on second toggle", () => {
|
|
const store = makeStore();
|
|
store.getState().toggleSubtaskCollapsed(TASK_A);
|
|
store.getState().toggleSubtaskCollapsed(TASK_A);
|
|
|
|
expect(store.getState().collapsedSubtaskParents).toEqual([]);
|
|
expect(JSON.parse(window.sessionStorage.getItem(KEY) ?? "null")).toEqual([]);
|
|
});
|
|
|
|
it("tracks multiple parents independently", () => {
|
|
const store = makeStore();
|
|
store.getState().toggleSubtaskCollapsed(TASK_A);
|
|
store.getState().toggleSubtaskCollapsed(TASK_B);
|
|
|
|
expect(store.getState().collapsedSubtaskParents).toEqual([TASK_A, TASK_B]);
|
|
|
|
store.getState().toggleSubtaskCollapsed(TASK_A);
|
|
expect(store.getState().collapsedSubtaskParents).toEqual([TASK_B]);
|
|
});
|
|
});
|
|
|
|
describe("sidebar task prefs (pin + manual order)", () => {
|
|
const PINNED_KEY = "kandev.sidebar.pinnedTaskIds";
|
|
const ORDER_KEY = "kandev.sidebar.orderedTaskIds";
|
|
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
it("hydrates pinned + ordered from localStorage", () => {
|
|
window.localStorage.setItem(PINNED_KEY, JSON.stringify(["t1"]));
|
|
window.localStorage.setItem(ORDER_KEY, JSON.stringify(["t2", "t1"]));
|
|
const store = makeStore();
|
|
expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t1"]);
|
|
expect(store.getState().sidebarTaskPrefs.orderedTaskIds).toEqual(["t2", "t1"]);
|
|
});
|
|
|
|
it("togglePinnedTask adds, removes, and persists", () => {
|
|
const store = makeStore();
|
|
store.getState().togglePinnedTask("t1");
|
|
expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t1"]);
|
|
expect(JSON.parse(window.localStorage.getItem(PINNED_KEY) ?? "null")).toEqual(["t1"]);
|
|
|
|
store.getState().togglePinnedTask("t2");
|
|
expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t1", "t2"]);
|
|
|
|
store.getState().togglePinnedTask("t1");
|
|
expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t2"]);
|
|
expect(JSON.parse(window.localStorage.getItem(PINNED_KEY) ?? "null")).toEqual(["t2"]);
|
|
});
|
|
|
|
it("setSidebarTaskOrder replaces and persists", () => {
|
|
const store = makeStore();
|
|
store.getState().setSidebarTaskOrder(["a", "b", "c"]);
|
|
expect(store.getState().sidebarTaskPrefs.orderedTaskIds).toEqual(["a", "b", "c"]);
|
|
expect(JSON.parse(window.localStorage.getItem(ORDER_KEY) ?? "null")).toEqual(["a", "b", "c"]);
|
|
|
|
store.getState().setSidebarTaskOrder(["c", "a"]);
|
|
expect(store.getState().sidebarTaskPrefs.orderedTaskIds).toEqual(["c", "a"]);
|
|
expect(JSON.parse(window.localStorage.getItem(ORDER_KEY) ?? "null")).toEqual(["c", "a"]);
|
|
});
|
|
|
|
it("removeTaskFromSidebarPrefs strips the id from both arrays and persists", () => {
|
|
const store = makeStore();
|
|
store.getState().togglePinnedTask("t1");
|
|
store.getState().togglePinnedTask("t2");
|
|
store.getState().setSidebarTaskOrder(["t1", "t2", "t3"]);
|
|
|
|
store.getState().removeTaskFromSidebarPrefs("t1");
|
|
|
|
expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t2"]);
|
|
expect(store.getState().sidebarTaskPrefs.orderedTaskIds).toEqual(["t2", "t3"]);
|
|
expect(JSON.parse(window.localStorage.getItem(PINNED_KEY) ?? "null")).toEqual(["t2"]);
|
|
expect(JSON.parse(window.localStorage.getItem(ORDER_KEY) ?? "null")).toEqual(["t2", "t3"]);
|
|
|
|
// Subsequent togglePinnedTask must NOT bring "t1" back from a stale draft.
|
|
store.getState().togglePinnedTask("t3");
|
|
expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t2", "t3"]);
|
|
expect(JSON.parse(window.localStorage.getItem(PINNED_KEY) ?? "null")).toEqual(["t2", "t3"]);
|
|
});
|
|
|
|
it("removeTaskFromSidebarPrefs is a no-op for unknown ids", () => {
|
|
const store = makeStore();
|
|
store.getState().togglePinnedTask("t1");
|
|
const before = window.localStorage.getItem(PINNED_KEY);
|
|
store.getState().removeTaskFromSidebarPrefs("ghost");
|
|
expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t1"]);
|
|
expect(window.localStorage.getItem(PINNED_KEY)).toBe(before);
|
|
});
|
|
});
|
|
|
|
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();
|
|
vi.mocked(updateUserSettings).mockClear();
|
|
});
|
|
|
|
it("reorders by id, persists the order, and syncs the backend payload", () => {
|
|
const store = makeStore();
|
|
store.setState((state) => ({
|
|
...state,
|
|
sidebarViews: {
|
|
...state.sidebarViews,
|
|
views: [
|
|
makeSidebarView("all", "All"),
|
|
makeSidebarView("one", "One"),
|
|
makeSidebarView("two", "Two"),
|
|
],
|
|
activeViewId: "two",
|
|
draft: null,
|
|
},
|
|
}));
|
|
|
|
store.getState().reorderSidebarViews("two", "one");
|
|
|
|
expect(store.getState().sidebarViews.views.map((v) => v.id)).toEqual(["all", "two", "one"]);
|
|
expect(JSON.parse(window.localStorage.getItem(SIDEBAR_VIEWS_KEY) ?? "[]")).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: "all" }),
|
|
expect.objectContaining({ id: "two" }),
|
|
expect.objectContaining({ id: "one" }),
|
|
]),
|
|
);
|
|
expect(
|
|
JSON.parse(window.localStorage.getItem(SIDEBAR_VIEWS_KEY) ?? "[]").map(
|
|
(v: { id: string }) => v.id,
|
|
),
|
|
).toEqual(["all", "two", "one"]);
|
|
expect(updateUserSettings).toHaveBeenCalledWith({
|
|
sidebar_views: [
|
|
expect.objectContaining({ id: "all" }),
|
|
expect.objectContaining({ id: "two" }),
|
|
expect.objectContaining({ id: "one" }),
|
|
],
|
|
});
|
|
});
|
|
|
|
it("keeps the active view and draft while reordering", () => {
|
|
const draft: SidebarViewDraft = {
|
|
baseViewId: "one",
|
|
filters: [{ id: "c1", dimension: "titleMatch", op: "matches", value: "bug" }],
|
|
sort: { key: "title", direction: "asc" },
|
|
group: "workflow",
|
|
};
|
|
const store = makeStore();
|
|
store.setState((state) => ({
|
|
...state,
|
|
sidebarViews: {
|
|
...state.sidebarViews,
|
|
views: [
|
|
makeSidebarView("all", "All"),
|
|
makeSidebarView("one", "One"),
|
|
makeSidebarView("two", "Two"),
|
|
],
|
|
activeViewId: "one",
|
|
draft,
|
|
},
|
|
}));
|
|
|
|
store.getState().reorderSidebarViews("two", "all");
|
|
|
|
expect(store.getState().sidebarViews.views.map((v) => v.id)).toEqual(["two", "all", "one"]);
|
|
expect(store.getState().sidebarViews.activeViewId).toBe("one");
|
|
expect(store.getState().sidebarViews.draft).toEqual(draft);
|
|
});
|
|
|
|
it("no-ops when ids are equal or missing", () => {
|
|
const store = makeStore();
|
|
const views = [
|
|
makeSidebarView("all", "All"),
|
|
makeSidebarView("one", "One"),
|
|
makeSidebarView("two", "Two"),
|
|
];
|
|
store.setState((state) => ({
|
|
...state,
|
|
sidebarViews: { ...state.sidebarViews, views, activeViewId: "all", draft: null },
|
|
}));
|
|
|
|
store.getState().reorderSidebarViews("one", "one");
|
|
store.getState().reorderSidebarViews("missing", "one");
|
|
store.getState().reorderSidebarViews("one", "missing");
|
|
|
|
expect(store.getState().sidebarViews.views.map((v) => v.id)).toEqual(["all", "one", "two"]);
|
|
expect(updateUserSettings).not.toHaveBeenCalled();
|
|
});
|
|
});
|