feat: allow reordering sidebar views (#764)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user