From 396325b2c99d6899dc88ef79a6c99e381a2e6404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Fri, 1 May 2026 23:50:05 +0100 Subject: [PATCH] feat: allow reordering sidebar views (#764) --- .../sidebar-filter/sidebar-view-chips.tsx | 163 +++++++++++++++--- apps/web/e2e/pages/sidebar-filter-popover.ts | 45 ++++- .../web/e2e/tests/task/sidebar-filter.spec.ts | 92 ++++++++++ apps/web/lib/state/slices/ui/types.ts | 1 + apps/web/lib/state/slices/ui/ui-slice.test.ts | 116 ++++++++++++- apps/web/lib/state/slices/ui/ui-slice.ts | 21 +++ apps/web/lib/state/store.ts | 1 + 7 files changed, 409 insertions(+), 30 deletions(-) diff --git a/apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx b/apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx index a91ac6c67b..ac74ee6792 100644 --- a/apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx +++ b/apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx @@ -1,40 +1,147 @@ "use client"; -import { cn } from "@/lib/utils"; +import { useCallback, useRef, type PointerEvent } from "react"; +import { + DndContext, + PointerSensor, + closestCenter, + type DragEndEvent, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { SortableContext, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { useAppStore } from "@/components/state-provider"; +import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types"; +import { cn } from "@/lib/utils"; + +const DRAG_ACTIVATION_DISTANCE = 8; export function SidebarViewChips() { const views = useAppStore((s) => s.sidebarViews.views); const activeViewId = useAppStore((s) => s.sidebarViews.activeViewId); const setActive = useAppStore((s) => s.setSidebarActiveView); + const reorderViews = useAppStore((s) => s.reorderSidebarViews); + 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; + reorderViews(String(active.id), String(over.id)); + }, + [reorderViews], + ); return ( -
- {views.map((view) => { - const active = view.id === activeViewId; - return ( - - ); - })} -
+ + view.id)} + strategy={horizontalListSortingStrategy} + > +
+ {views.map((view) => ( + setActive(view.id)} + /> + ))} +
+
+
+ ); +} + +function SidebarViewChip({ + view, + active, + onSelect, +}: { + view: SidebarView; + active: boolean; + onSelect: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: view.id, + }); + const sortableAttributes = { + ...attributes, + role: undefined, + "aria-roledescription": undefined, + }; + const pointerStartRef = useRef<{ x: number; y: number } | null>(null); + const pointerMovedRef = useRef(false); + const skipNextClickRef = useRef(false); + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : undefined, + }; + const resetPointerState = useCallback(() => { + pointerStartRef.current = null; + pointerMovedRef.current = false; + }, []); + const handlePointerDownCapture = useCallback((event: PointerEvent) => { + pointerStartRef.current = { x: event.clientX, y: event.clientY }; + pointerMovedRef.current = false; + }, []); + const handlePointerMoveCapture = useCallback((event: PointerEvent) => { + const start = pointerStartRef.current; + if (!start) return; + const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y); + if (distance >= DRAG_ACTIVATION_DISTANCE) pointerMovedRef.current = true; + }, []); + const handlePointerUpCapture = useCallback(() => { + const shouldSelect = pointerStartRef.current && !pointerMovedRef.current; + resetPointerState(); + if (!shouldSelect) return; + skipNextClickRef.current = true; + window.setTimeout(() => { + skipNextClickRef.current = false; + }, 0); + onSelect(); + }, [onSelect, resetPointerState]); + const handleClick = useCallback(() => { + if (skipNextClickRef.current) { + skipNextClickRef.current = false; + return; + } + onSelect(); + }, [onSelect]); + + return ( + ); } diff --git a/apps/web/e2e/pages/sidebar-filter-popover.ts b/apps/web/e2e/pages/sidebar-filter-popover.ts index c81cb9ae43..6aac42a374 100644 --- a/apps/web/e2e/pages/sidebar-filter-popover.ts +++ b/apps/web/e2e/pages/sidebar-filter-popover.ts @@ -30,7 +30,7 @@ export class SidebarFilterPopoverPage { } async selectViewByName(name: string): Promise { - await this.chipRow.locator(`[data-testid='sidebar-view-chip']`, { hasText: name }).click(); + await this.chipByName(name).click(); } async expectActiveViewChip(name: string): Promise { @@ -40,6 +40,49 @@ export class SidebarFilterPopoverPage { await expect(chip).toContainText(name); } + chipByName(name: string): Locator { + return this.chipRow.getByTestId("sidebar-view-chip").filter({ hasText: name }).first(); + } + + async expectChipOrder(names: string[]): Promise { + await expect + .poll(async () => { + const texts = await this.chipRow.getByTestId("sidebar-view-chip").allTextContents(); + return texts.map((text) => text.trim()); + }) + .toEqual(names); + } + + async dragViewBefore(sourceName: string, targetName: string): Promise { + const source = this.chipByName(sourceName); + const target = this.chipByName(targetName); + await source.scrollIntoViewIfNeeded(); + await target.scrollIntoViewIfNeeded(); + await expect(source).toBeVisible(); + + const sourceBox = await source.boundingBox(); + const targetBox = await target.boundingBox(); + if (!sourceBox || !targetBox) throw new Error("Missing sidebar view chip drag geometry"); + const targetLeftHalfX = targetBox.x + targetBox.width * 0.25; + + await this.page.mouse.move( + sourceBox.x + sourceBox.width / 2, + sourceBox.y + sourceBox.height / 2, + ); + await this.page.mouse.down(); + // Move far enough to exceed the 8px PointerSensor activation threshold, + // then slide toward the target. + await this.page.mouse.move( + sourceBox.x + sourceBox.width / 2 - 16, + sourceBox.y + sourceBox.height / 2, + { steps: 4 }, + ); + await this.page.mouse.move(targetLeftHalfX, targetBox.y + targetBox.height / 2, { + steps: 20, + }); + await this.page.mouse.up(); + } + async addFilterRow(): Promise { await this.open(); await this.popover.getByTestId("filter-add-button").click(); diff --git a/apps/web/e2e/tests/task/sidebar-filter.spec.ts b/apps/web/e2e/tests/task/sidebar-filter.spec.ts index 3a7ccdc061..5b40b68db6 100644 --- a/apps/web/e2e/tests/task/sidebar-filter.spec.ts +++ b/apps/web/e2e/tests/task/sidebar-filter.spec.ts @@ -42,6 +42,35 @@ async function openWithSeed( return { session, filters }; } +async function saveTitleView( + filters: SidebarFilterPopoverPage, + name: string, + titleFilter: string, +): Promise { + await filters.close(); + await filters.selectViewByName("All tasks"); + await filters.expectActiveViewChip("All tasks"); + await filters.addFilterRow(); + await filters.setClauseDimension(0, "Title"); + await filters.setClauseTextValue(0, titleFilter); + await filters.saveAs(name); + await filters.expectActiveViewChip(name); + await filters.close(); +} + +async function expectStoredSidebarViewOrder( + apiClient: import("../../helpers/api-client").ApiClient, + names: string[], +): Promise { + await expect + .poll(async () => { + const response = await apiClient.getUserSettings(); + const views = (response.settings.sidebar_views ?? []) as Array<{ name: string }>; + return views.map((view) => view.name); + }) + .toEqual(names); +} + test.describe("Sidebar filter bar — popover basics", () => { test("gear opens popover; ESC closes it", async ({ testPage, apiClient, seedData }) => { const { filters } = await openWithSeed(testPage, apiClient, seedData, ["Basics Task"]); @@ -82,6 +111,69 @@ test.describe("Sidebar filter bar — popover basics", () => { }); }); +test.describe("Sidebar filter — view ordering", () => { + test("dragged view order persists to settings and survives reload", async ({ + testPage, + apiClient, + seedData, + }) => { + const { filters } = await openWithSeed(testPage, apiClient, seedData, [ + "Order alpha task", + "Order beta task", + "Order gamma task", + ]); + await saveTitleView(filters, "Alpha View", "alpha"); + await saveTitleView(filters, "Beta View", "beta"); + await saveTitleView(filters, "Gamma View", "gamma"); + await filters.expectChipOrder(["All tasks", "Alpha View", "Beta View", "Gamma View"]); + + await filters.dragViewBefore("Gamma View", "All tasks"); + + const reorderedNames = ["Gamma View", "All tasks", "Alpha View", "Beta View"]; + await filters.expectChipOrder(reorderedNames); + await expectStoredSidebarViewOrder(apiClient, reorderedNames); + + await testPage.reload(); + const session = new SessionPage(testPage); + await session.waitForLoad(); + const filters2 = new SidebarFilterPopoverPage(testPage); + await filters2.expectChipOrder(reorderedNames); + await filters2.expectActiveViewChip("Gamma View"); + }); + + test("reordered views still select, delete, and append normally", async ({ + testPage, + apiClient, + seedData, + }) => { + const { session, filters } = await openWithSeed(testPage, apiClient, seedData, [ + "Select alpha task", + "Select beta task", + "Select gamma task", + ]); + await saveTitleView(filters, "Alpha View", "alpha"); + await saveTitleView(filters, "Beta View", "beta"); + await saveTitleView(filters, "Gamma View", "gamma"); + await filters.dragViewBefore("Gamma View", "All tasks"); + await filters.expectChipOrder(["Gamma View", "All tasks", "Alpha View", "Beta View"]); + + await filters.selectViewByName("Alpha View"); + await filters.expectActiveViewChip("Alpha View"); + await expect(session.sidebar.getByText("Select alpha task")).toBeVisible(); + await expect(session.sidebar.getByText("Select beta task")).toHaveCount(0); + + await filters.selectViewByName("Beta View"); + await filters.open(); + await filters.deleteActiveView(); + await filters.expectChipOrder(["Gamma View", "All tasks", "Alpha View"]); + await filters.expectActiveViewChip("Gamma View"); + + await saveTitleView(filters, "Delta View", "delta"); + await filters.expectChipOrder(["Gamma View", "All tasks", "Alpha View", "Delta View"]); + await filters.expectActiveViewChip("Delta View"); + }); +}); + test.describe("Sidebar filter — filtering", () => { test("adding a title filter narrows the list live", async ({ testPage, apiClient, seedData }) => { const { session, filters } = await openWithSeed(testPage, apiClient, seedData, [ diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index bceb5cf9a2..aedf9c1e5f 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -165,6 +165,7 @@ export type UISliceActions = { deleteSidebarView: (viewId: string) => void; renameSidebarView: (viewId: string, name: string) => void; duplicateSidebarView: (viewId: string, name: string) => void; + reorderSidebarViews: (activeViewId: string, overViewId: string) => void; toggleSidebarGroupCollapsed: (viewId: string, groupKey: string) => void; toggleSubtaskCollapsed: (parentTaskId: string) => void; clearSidebarSyncError: () => void; diff --git a/apps/web/lib/state/slices/ui/ui-slice.test.ts b/apps/web/lib/state/slices/ui/ui-slice.test.ts index 3c59821537..975bb2eec8 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.test.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.test.ts @@ -1,9 +1,15 @@ -import { beforeEach, describe, expect, it } from "vitest"; +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()( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -14,6 +20,18 @@ function makeStore() { 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(() => { @@ -54,3 +72,99 @@ describe("toggleSubtaskCollapsed", () => { expect(store.getState().collapsedSubtaskParents).toEqual([TASK_B]); }); }); + +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(); + }); +}); diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index daa5a58419..10005d25e8 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -71,6 +71,21 @@ function makeId(prefix: string): string { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } +function reorderViewsById( + views: SidebarView[], + activeViewId: string, + overViewId: string, +): SidebarView[] | null { + if (activeViewId === overViewId) return null; + const oldIndex = views.findIndex((v) => v.id === activeViewId); + const newIndex = views.findIndex((v) => v.id === overViewId); + if (oldIndex === -1 || newIndex === -1) return null; + const next = [...views]; + const [moved] = next.splice(oldIndex, 1); + next.splice(newIndex, 0, moved); + return next; +} + export const defaultUIState: UISliceState = { previewPanel: { openBySessionId: {}, @@ -372,6 +387,12 @@ function buildSidebarBackendActions(set: ImmerSet, get: () => UISlice) { if (!next || next === view.name) return false; view.name = next; }), + reorderSidebarViews: (activeViewId: string, overViewId: string) => + mv((s) => { + const reordered = reorderViewsById(s.views, activeViewId, overViewId); + if (!reordered) return false; + s.views = reordered; + }), }; } diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index ac6b33db45..58e0e4ac83 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -475,6 +475,7 @@ export type AppState = { deleteSidebarView: UIA["deleteSidebarView"]; renameSidebarView: UIA["renameSidebarView"]; duplicateSidebarView: UIA["duplicateSidebarView"]; + reorderSidebarViews: UIA["reorderSidebarViews"]; toggleSidebarGroupCollapsed: UIA["toggleSidebarGroupCollapsed"]; toggleSubtaskCollapsed: UIA["toggleSubtaskCollapsed"]; clearSidebarSyncError: UIA["clearSidebarSyncError"];