Files
kandev/apps/web/components/task/layout-preset-selector.test.tsx
T
2026-09-12 14:58:49 +02:00

299 lines
11 KiB
TypeScript

import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getBuiltInLayoutProfile } from "@/lib/layout/layout-profiles";
const mocks = vi.hoisted(() => ({
finePointer: true,
isMobile: false,
savedLayouts: [] as Array<Record<string, unknown>>,
updateUserSettings: vi.fn(),
setUserSettings: vi.fn(),
toastError: vi.fn(),
applyCustomLayout: vi.fn(),
applyBuiltInPreset: vi.fn(),
resetLayout: vi.fn(),
tooltipOpen: false,
requestTooltipOpen: null as ((open: boolean) => void) | null,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, values?: { name?: string }) => {
if (key === "task:delete2") return `Delete ${values?.name ?? "saved layout"}`;
if (key === "task:deleteLayoutConfirm") return `Delete ${values?.name ?? "saved layout"}?`;
return key;
},
}),
}));
vi.mock("@/lib/i18n", () => ({
t: (key: string) => key,
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isFinePointer: mocks.finePointer, isMobile: mocks.isMobile }),
}));
vi.mock("@/lib/toast/sonner", () => ({
toast: { error: (...args: unknown[]) => mocks.toastError(...args) },
}));
vi.mock("@/lib/api/domains/settings-api", () => ({
updateUserSettings: (...args: unknown[]) => mocks.updateUserSettings(...args),
}));
vi.mock("@/lib/ssr/user-settings", () => ({
mapUserSettingsResponse: (response: { settings: { saved_layouts: unknown[] } }) => ({
savedLayouts: response.settings.saved_layouts,
}),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
userSettings: { savedLayouts: mocks.savedLayouts },
tasks: { activeTaskId: "task-1", activeSessionId: "session-1" },
setUserSettings: mocks.setUserSettings,
}),
useAppStoreApi: () => ({
getState: () => ({
userSettings: { savedLayouts: mocks.savedLayouts },
setUserSettings: mocks.setUserSettings,
taskSessionsByTask: { itemsByTaskId: { "task-1": [] } },
}),
}),
}));
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: (selector: (state: unknown) => unknown) =>
selector({
applyCustomLayout: mocks.applyCustomLayout,
applyBuiltInPreset: mocks.applyBuiltInPreset,
resetLayout: mocks.resetLayout,
captureCurrentLayout: vi.fn(() => getBuiltInLayoutProfile("default").layout),
}),
}));
vi.mock("@/hooks/use-task-sessions", () => ({
useTaskSessions: () => ({ isLoaded: true, loadSessions: vi.fn() }),
}));
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) => {
mocks.tooltipOpen = open ?? false;
mocks.requestTooltipOpen = onOpenChange ?? null;
return <>{children}</>;
},
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import { LayoutPresetSelector } from "./layout-preset-selector";
const SAVED_DELETE_TEST_ID = "layout-saved-delete";
const SAVED_DELETE_POPOVER_TEST_ID = "layout-saved-delete-confirm-popover";
const SAVED_DELETE_CONFIRM_TEST_ID = "layout-saved-delete-confirm";
function savedLayout(overrides: Record<string, unknown> = {}) {
return {
id: "saved-layout",
name: "Saved layout",
is_default: false,
layout: getBuiltInLayoutProfile("default").layout,
created_at: "2026-08-20T00:00:00.000Z",
...overrides,
};
}
function openSavedLayouts() {
const trigger = screen.getByTestId("layout-preset-trigger");
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: "mouse" });
fireEvent.pointerUp(trigger, { button: 0, ctrlKey: false, pointerType: "mouse" });
fireEvent.click(trigger);
return waitFor(() =>
expect(screen.getAllByTestId(SAVED_DELETE_TEST_ID).length).toBeGreaterThan(0),
);
}
function deleteAction(layoutId: string) {
const action = screen
.getAllByTestId(SAVED_DELETE_TEST_ID)
.find((candidate) => candidate.getAttribute("data-layout-id") === layoutId);
expect(action).toBeTruthy();
return action!;
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
beforeEach(() => {
mocks.finePointer = true;
mocks.isMobile = false;
mocks.savedLayouts = [savedLayout()];
mocks.updateUserSettings.mockReset();
mocks.setUserSettings.mockReset();
mocks.toastError.mockReset();
mocks.applyCustomLayout.mockReset();
mocks.applyBuiltInPreset.mockReset();
mocks.resetLayout.mockReset();
mocks.tooltipOpen = false;
mocks.requestTooltipOpen = null;
mocks.setUserSettings.mockImplementation(
(settings: { savedLayouts?: Array<Record<string, unknown>> }) => {
if (settings.savedLayouts) mocks.savedLayouts = settings.savedLayouts;
},
);
mocks.updateUserSettings.mockResolvedValue({ settings: { saved_layouts: [] } });
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("LayoutPresetSelector saved-layout deletion", () => {
it("hands the phone menu off to a sheet without applying the layout", async () => {
mocks.isMobile = true;
mocks.finePointer = false;
render(<LayoutPresetSelector />);
const trigger = screen.getByTestId("layout-preset-trigger");
await openSavedLayouts();
fireEvent.click(screen.getByTestId(SAVED_DELETE_TEST_ID));
const sheet = await screen.findByRole("dialog", { name: "Delete Saved layout?" });
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(document.querySelector('[data-slot="dropdown-menu-content"]')).toBeNull();
fireEvent.click(within(sheet).getByRole("button", { name: "common:cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
expect(mocks.applyCustomLayout).not.toHaveBeenCalled();
expect(mocks.updateUserSettings).not.toHaveBeenCalled();
await openSavedLayouts();
fireEvent.click(screen.getByTestId(SAVED_DELETE_TEST_ID));
fireEvent.click(await screen.findByTestId(SAVED_DELETE_CONFIRM_TEST_ID));
await waitFor(() => expect(mocks.updateUserSettings).toHaveBeenCalledTimes(1));
expect(mocks.savedLayouts).toEqual([]);
});
it("anchors desktop confirmation to the menu action and keeps cancel local", async () => {
render(<LayoutPresetSelector />);
await openSavedLayouts();
fireEvent.click(screen.getByTestId(SAVED_DELETE_TEST_ID));
const popover = screen.getByTestId(SAVED_DELETE_POPOVER_TEST_ID);
expect(popover).toBeTruthy();
expect(popover.hasAttribute("data-confirmation-boundary")).toBe(true);
expect(screen.queryByRole("alertdialog")).toBeNull();
fireEvent.click(
within(screen.getByTestId(SAVED_DELETE_POPOVER_TEST_ID)).getByRole("button", {
name: "common:cancel",
}),
);
expect(mocks.updateUserSettings).not.toHaveBeenCalled();
});
it("morphs the menu row into touch-sized actions without stacking an overlay", async () => {
mocks.finePointer = false;
render(<LayoutPresetSelector />);
await openSavedLayouts();
fireEvent.click(screen.getByTestId(SAVED_DELETE_TEST_ID));
const inline = screen.getByTestId("layout-saved-delete-inline-confirmation");
expect(screen.queryByTestId(SAVED_DELETE_POPOVER_TEST_ID)).toBeNull();
expect(screen.queryByRole("alertdialog")).toBeNull();
expect(within(inline).getByTestId("layout-saved-delete-confirm").className).toContain(
"min-w-11",
);
fireEvent.click(within(inline).getByTestId(SAVED_DELETE_CONFIRM_TEST_ID));
await waitFor(() =>
expect(mocks.updateUserSettings).toHaveBeenCalledWith({ saved_layouts: [] }),
);
});
});
describe("LayoutPresetSelector saved-layout persistence", () => {
it("serializes deletions and derives each payload from the latest saved layouts", async () => {
const first = savedLayout({ id: "first-layout", name: "First layout" });
const second = savedLayout({ id: "second-layout", name: "Second layout" });
const retained = savedLayout({ id: "retained-layout", name: "Retained layout" });
mocks.savedLayouts = [first, second, retained];
const firstResponse = deferred<{
settings: { saved_layouts: Array<Record<string, unknown>> };
}>();
const secondResponse = deferred<{
settings: { saved_layouts: Array<Record<string, unknown>> };
}>();
mocks.updateUserSettings
.mockReturnValueOnce(firstResponse.promise)
.mockReturnValueOnce(secondResponse.promise);
render(<LayoutPresetSelector />);
await openSavedLayouts();
fireEvent.click(deleteAction("first-layout"));
fireEvent.click(screen.getByTestId(SAVED_DELETE_CONFIRM_TEST_ID));
await waitFor(() => expect(mocks.updateUserSettings).toHaveBeenCalledTimes(1));
await openSavedLayouts();
fireEvent.click(deleteAction("second-layout"));
fireEvent.click(screen.getByTestId(SAVED_DELETE_CONFIRM_TEST_ID));
expect(mocks.updateUserSettings).toHaveBeenCalledTimes(1);
firstResponse.resolve({ settings: { saved_layouts: [second, retained] } });
await waitFor(() => expect(mocks.updateUserSettings).toHaveBeenCalledTimes(2));
expect(mocks.updateUserSettings).toHaveBeenLastCalledWith({ saved_layouts: [retained] });
secondResponse.resolve({ settings: { saved_layouts: [retained] } });
});
it("suppresses tooltip reopening after confirmation and clears the close timer on unmount", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
const { unmount } = render(<LayoutPresetSelector />);
await openSavedLayouts();
fireEvent.click(screen.getByTestId(SAVED_DELETE_TEST_ID));
fireEvent.click(screen.getByTestId(SAVED_DELETE_CONFIRM_TEST_ID));
await waitFor(() => expect(mocks.updateUserSettings).toHaveBeenCalledTimes(1));
const timerCallIndex = setTimeoutSpy.mock.calls.findIndex(([, delay]) => delay === 200);
expect(timerCallIndex).toBeGreaterThanOrEqual(0);
const timerId = setTimeoutSpy.mock.results[timerCallIndex]?.value;
act(() => mocks.requestTooltipOpen?.(true));
expect(mocks.tooltipOpen).toBe(false);
unmount();
expect(clearTimeoutSpy).toHaveBeenCalledWith(timerId);
});
it("keeps the existing localized error toast when deletion fails", async () => {
mocks.updateUserSettings.mockRejectedValueOnce(new Error("delete failed"));
render(<LayoutPresetSelector />);
await openSavedLayouts();
fireEvent.click(screen.getByTestId(SAVED_DELETE_TEST_ID));
fireEvent.click(
within(screen.getByTestId(SAVED_DELETE_POPOVER_TEST_ID)).getByTestId(
SAVED_DELETE_CONFIRM_TEST_ID,
),
);
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1));
expect(mocks.toastError).toHaveBeenCalledWith("task:failedToDeleteLayout", {
description: "delete failed",
});
});
});