feat: allow reordering sidebar views (#764)

This commit is contained in:
José Almeida
2026-05-01 23:50:05 +01:00
committed by GitHub
parent 7d93d52c7a
commit 396325b2c9
7 changed files with 409 additions and 30 deletions
@@ -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 (
<div
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
data-testid="sidebar-view-chip-row"
>
{views.map((view) => {
const active = view.id === activeViewId;
return (
<button
type="button"
key={view.id}
onClick={() => setActive(view.id)}
data-testid="sidebar-view-chip"
data-view-id={view.id}
data-active={active}
className={cn(
"h-6 shrink-0 cursor-pointer rounded-md border px-2 text-[11px] transition-colors",
active
? "border-primary/40 bg-primary/10 text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
title={view.name}
>
<span className="truncate max-w-[120px]">{view.name}</span>
</button>
);
})}
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={views.map((view) => view.id)}
strategy={horizontalListSortingStrategy}
>
<div
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
data-testid="sidebar-view-chip-row"
>
{views.map((view) => (
<SidebarViewChip
key={view.id}
view={view}
active={view.id === activeViewId}
onSelect={() => setActive(view.id)}
/>
))}
</div>
</SortableContext>
</DndContext>
);
}
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<HTMLButtonElement>) => {
pointerStartRef.current = { x: event.clientX, y: event.clientY };
pointerMovedRef.current = false;
}, []);
const handlePointerMoveCapture = useCallback((event: PointerEvent<HTMLButtonElement>) => {
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 (
<button
ref={setNodeRef}
style={style}
type="button"
{...sortableAttributes}
{...listeners}
onPointerDownCapture={handlePointerDownCapture}
onPointerMoveCapture={handlePointerMoveCapture}
onPointerUpCapture={handlePointerUpCapture}
onPointerCancelCapture={resetPointerState}
data-testid="sidebar-view-chip"
data-view-id={view.id}
data-active={active}
aria-pressed={active}
className={cn(
"flex h-6 shrink-0 cursor-pointer items-center rounded-md border px-2 text-left text-[11px] transition-colors active:cursor-grabbing",
active
? "border-primary/40 bg-primary/10 text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
isDragging && "z-50 cursor-grabbing",
)}
title={view.name}
onClick={handleClick}
>
<span className="block max-w-[120px] truncate">{view.name}</span>
</button>
);
}
+44 -1
View File
@@ -30,7 +30,7 @@ export class SidebarFilterPopoverPage {
}
async selectViewByName(name: string): Promise<void> {
await this.chipRow.locator(`[data-testid='sidebar-view-chip']`, { hasText: name }).click();
await this.chipByName(name).click();
}
async expectActiveViewChip(name: string): Promise<void> {
@@ -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<void> {
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<void> {
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<void> {
await this.open();
await this.popover.getByTestId("filter-add-button").click();
@@ -42,6 +42,35 @@ async function openWithSeed(
return { session, filters };
}
async function saveTitleView(
filters: SidebarFilterPopoverPage,
name: string,
titleFilter: string,
): Promise<void> {
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<void> {
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, [
+1
View File
@@ -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;
+115 -1
View File
@@ -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<UISlice>()(
// 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();
});
});
+21
View File
@@ -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;
}),
};
}
+1
View File
@@ -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"];