Files
kandev/apps/web/components/kanban/mobile-menu-sheet.test.tsx
2026-09-14 09:26:24 +01:00

327 lines
12 KiB
TypeScript

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MobileMenuSheet } from "./mobile-menu-sheet";
const { useKanbanDisplaySettingsMock, breakpointMocks, storeMocks, pluginFilterMocks } = vi.hoisted(
() => ({
useKanbanDisplaySettingsMock: vi.fn(),
breakpointMocks: { isMobile: true, isTablet: false, breakpoint: "mobile" as string },
storeMocks: { focusedWorkflowId: null as string | null },
pluginFilterMocks: {
filters: [] as Array<{
pluginId: string;
id: string;
label: string;
getOptions: () => Array<{ value: string; label: string }>;
matches: () => boolean;
}>,
selections: {} as Record<string, string[]>,
setFilterSelection: vi.fn(),
},
}),
);
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({ mobileKanban: { focusedWorkflowId: storeMocks.focusedWorkflowId } }),
}));
afterEach(cleanup);
vi.mock("@/hooks/use-kanban-display-settings", () => ({
useKanbanDisplaySettings: useKanbanDisplaySettingsMock,
}));
vi.mock("@/hooks/use-plugin-task-filters", () => ({
usePluginTaskFilters: () => pluginFilterMocks,
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => breakpointMocks,
}));
vi.mock("@/lib/routing/client-router", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/components/app-sidebar/app-sidebar-workspace-picker", () => ({
AppSidebarWorkspacePicker: () => null,
}));
// Plugins, integrations and the utility tail now come from the one shared nav
// block, which reaches the store and the health poller — out of scope here.
vi.mock("@/components/navigation/app-nav-sections", () => ({
AppNavSections: () => null,
useAppNavDialogs: () => ({
showHealthRow: false,
openImproveKandev: vi.fn(),
openHealthDialog: vi.fn(),
dialogs: null,
}),
}));
vi.mock("./task-search-input", () => ({
TaskSearchInput: () => null,
}));
vi.mock("@kandev/ui/drawer", () => ({
Drawer: ({ children }: { children: ReactNode }) => <>{children}</>,
DrawerContent: ({ children, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
DrawerHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DrawerTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
}));
vi.mock("@kandev/ui/sheet", () => ({
Sheet: ({ children }: { children: ReactNode }) => <>{children}</>,
SheetContent: ({ children, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
SheetHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
}));
function defaultDisplaySettings() {
return {
workflows: [],
activeWorkflowId: null,
repositories: [],
repositoriesLoading: false,
allRepositoriesSelected: true,
selectedRepositoryId: null,
enablePreviewOnClick: false,
tasksListShowDetails: false,
eligibleWorkflows: [],
snapshots: {},
hiddenWorkflowStepIds: {},
workflowIdsWithAutoHideEmptySteps: [],
boardSort: "created_desc" as "created_desc" | "priority_desc",
priorityFilterTokens: [] as string[],
onWorkflowChange: vi.fn(),
onRepositoryChange: vi.fn(),
onTogglePreviewOnClick: vi.fn(),
onToggleTasksListShowDetails: vi.fn(),
onToggleStepVisibility: vi.fn(),
onToggleAutoHideEmpty: vi.fn(),
effectiveTaskListingView: "kanban",
onViewModeChange: vi.fn(),
onBoardSortChange: vi.fn(),
onPriorityFilterChange: vi.fn(),
};
}
function renderSheet(props: Partial<ComponentProps<typeof MobileMenuSheet>> = {}) {
return render(<MobileMenuSheet open onOpenChange={vi.fn()} currentPage="kanban" {...props} />);
}
function expandGroup(group: "filters" | "sort" | "preview" | "list-rows") {
fireEvent.click(mobileGroupToggle(group));
}
const MOBILE_DISPLAY_SETTINGS_PREFIX = "mobile-display-settings-";
const MOBILE_BOARD_SORT_TEST_ID = "mobile-board-sort";
const MOBILE_CRITICAL_PRIORITY_TEST_ID = "mobile-priority-filter-option-critical";
const ARIA_EXPANDED_ATTRIBUTE = "aria-expanded";
function mobileGroupToggle(group: "filters" | "sort" | "preview" | "list-rows") {
return screen.getByTestId(`${MOBILE_DISPLAY_SETTINGS_PREFIX}${group}-toggle`);
}
beforeEach(() => {
useKanbanDisplaySettingsMock.mockReturnValue(defaultDisplaySettings());
breakpointMocks.isMobile = true;
breakpointMocks.isTablet = false;
breakpointMocks.breakpoint = "mobile";
storeMocks.focusedWorkflowId = null;
pluginFilterMocks.filters = [];
pluginFilterMocks.selections = {};
pluginFilterMocks.setFilterSelection.mockClear();
});
describe("MobileMenuSheet — grouped display settings", () => {
it("starts collapsed and expands each available group independently", () => {
renderSheet();
for (const group of ["filters", "sort", "preview"] as const) {
expect(mobileGroupToggle(group).getAttribute(ARIA_EXPANDED_ATTRIBUTE)).toBe("false");
}
expect(screen.queryByTestId(MOBILE_BOARD_SORT_TEST_ID)).toBeNull();
expect(screen.queryByTestId(MOBILE_CRITICAL_PRIORITY_TEST_ID)).toBeNull();
expandGroup("filters");
expect(screen.getByTestId(MOBILE_CRITICAL_PRIORITY_TEST_ID)).not.toBeNull();
expect(mobileGroupToggle("sort").getAttribute(ARIA_EXPANDED_ATTRIBUTE)).toBe("false");
expandGroup("sort");
expect(screen.getByTestId(MOBILE_BOARD_SORT_TEST_ID)).not.toBeNull();
expandGroup("preview");
expect(screen.getByTestId("mobile-display-preview-toggle")).not.toBeNull();
});
it("uses the same summaries and keeps list rows page-specific", () => {
useKanbanDisplaySettingsMock.mockReturnValue({
...defaultDisplaySettings(),
workflows: [{ id: "wf-a", name: "Workflow A" }],
activeWorkflowId: "wf-a",
repositories: [{ id: "repo-a", name: "Repository A" }],
allRepositoriesSelected: false,
selectedRepositoryId: "repo-a",
enablePreviewOnClick: true,
boardSort: "priority_desc",
priorityFilterTokens: ["critical"],
});
pluginFilterMocks.filters = [
{
pluginId: "plugin-a",
id: "tags",
label: "Tags",
getOptions: () => [{ value: "bug", label: "Bug" }],
matches: () => true,
},
];
pluginFilterMocks.selections = { "plugin-a:tags": ["bug"] };
renderSheet();
expect(screen.getByTestId("mobile-display-settings-filters").textContent).toContain(
"Repository A, Critical, Tags: 1 selected",
);
expect(screen.getByTestId("mobile-display-settings-sort").textContent).toContain("Priority");
expect(screen.getByTestId("mobile-display-settings-preview").textContent).toContain("On");
cleanup();
renderSheet({ currentPage: "tasks" });
expect(mobileGroupToggle("list-rows").getAttribute(ARIA_EXPANDED_ATTRIBUTE)).toBe("false");
expect(mobileGroupToggle("preview").getAttribute(ARIA_EXPANDED_ATTRIBUTE)).toBe("false");
expect(screen.queryByTestId("mobile-display-preview-toggle")).toBeNull();
expandGroup("list-rows");
expect(screen.getByTestId("mobile-display-task-details-toggle")).not.toBeNull();
});
});
describe("MobileMenuSheet — no Steps section (relocated to the lane)", () => {
it("renders no step control on the phone kanban page", () => {
// The dropdown-era Steps section is gone; the phone regains column
// visibility as a focused-workflow block in task-03.
renderSheet();
expect(screen.queryByTestId(/steps-filter-/)).toBeNull();
expect(screen.queryByTestId("steps-filter-section")).toBeNull();
});
});
const WF_A = { id: "wf-a", name: "Workflow A" };
const WF_B = { id: "wf-b", name: "Workflow B" };
const TWO_WORKFLOWS = {
eligibleWorkflows: [WF_A, WF_B],
snapshots: {
[WF_A.id]: { steps: [{ id: "a1", title: "Step A1", position: 0 }] },
[WF_B.id]: { steps: [{ id: "b1", title: "Step B1", position: 0 }] },
},
};
describe("MobileMenuSheet — Columns control for the focused workflow", () => {
it("renders the Columns menu for the focused workflow only", () => {
useKanbanDisplaySettingsMock.mockReturnValue({ ...defaultDisplaySettings(), ...TWO_WORKFLOWS });
storeMocks.focusedWorkflowId = WF_A.id;
renderSheet();
expect(screen.getByTestId(`columns-menu-${WF_A.id}`)).toBeTruthy();
expect(screen.queryByTestId(`columns-menu-${WF_B.id}`)).toBeNull();
});
it("renders nothing when the board has reported no focused workflow", () => {
useKanbanDisplaySettingsMock.mockReturnValue({ ...defaultDisplaySettings(), ...TWO_WORKFLOWS });
storeMocks.focusedWorkflowId = null;
renderSheet();
expect(screen.queryByTestId(/columns-menu-/)).toBeNull();
});
it("renders nothing off the phone, where the lane header owns the control", () => {
useKanbanDisplaySettingsMock.mockReturnValue({ ...defaultDisplaySettings(), ...TWO_WORKFLOWS });
storeMocks.focusedWorkflowId = WF_A.id;
breakpointMocks.isMobile = false;
renderSheet();
expect(screen.queryByTestId(/columns-menu-/)).toBeNull();
});
it("renders nothing on the tasks page", () => {
useKanbanDisplaySettingsMock.mockReturnValue({ ...defaultDisplaySettings(), ...TWO_WORKFLOWS });
storeMocks.focusedWorkflowId = WF_A.id;
renderSheet({ currentPage: "tasks" });
expect(screen.queryByTestId(/columns-menu-/)).toBeNull();
});
it("hides controls that Threads does not apply", () => {
useKanbanDisplaySettingsMock.mockReturnValue({
...defaultDisplaySettings(),
repositories: [{ id: "repo-1", name: "Repository 1" }],
});
renderSheet({ currentPage: "threads" });
expect(screen.queryByText("Repository")).toBeNull();
expect(screen.queryByText("Preview panel")).toBeNull();
expandGroup("filters");
expect(screen.getByTestId("mobile-display-workflow-filter")).not.toBeNull();
});
});
describe("MobileMenuSheet — board sort and priority filter", () => {
it("renders the board sort select and priority filter checkboxes on the phone kanban page", () => {
renderSheet({ currentPage: "kanban" });
expandGroup("filters");
expandGroup("sort");
expect(screen.getByTestId(MOBILE_BOARD_SORT_TEST_ID)).not.toBeNull();
expect(screen.getByTestId(MOBILE_CRITICAL_PRIORITY_TEST_ID)).not.toBeNull();
expect(screen.getByTestId("mobile-priority-filter-option-high")).not.toBeNull();
expect(screen.getByTestId("mobile-priority-filter-option-medium")).not.toBeNull();
expect(screen.getByTestId("mobile-priority-filter-option-low")).not.toBeNull();
});
it("names the board sort select by what it selects, not its current value", () => {
renderSheet({ currentPage: "kanban" });
expandGroup("sort");
expect(screen.getByRole("combobox", { name: "Board sort" })).not.toBeNull();
});
it("renders nothing on the tasks page", () => {
renderSheet({ currentPage: "tasks" });
expect(screen.queryByTestId(MOBILE_BOARD_SORT_TEST_ID)).toBeNull();
expect(screen.queryByTestId(/mobile-priority-filter-option-/)).toBeNull();
});
it("reflects the current priority filter selection", () => {
useKanbanDisplaySettingsMock.mockReturnValue({
...defaultDisplaySettings(),
priorityFilterTokens: ["critical"],
});
renderSheet({ currentPage: "kanban" });
expandGroup("filters");
expect(screen.getByTestId(MOBILE_CRITICAL_PRIORITY_TEST_ID).getAttribute("data-state")).toBe(
"checked",
);
expect(
screen.getByTestId("mobile-priority-filter-option-high").getAttribute("data-state"),
).toBe("unchecked");
});
it("invokes onPriorityFilterChange when tapping a priority option", () => {
const onPriorityFilterChange = vi.fn();
useKanbanDisplaySettingsMock.mockReturnValue({
...defaultDisplaySettings(),
onPriorityFilterChange,
});
renderSheet({ currentPage: "kanban" });
expandGroup("filters");
screen.getByTestId("mobile-priority-filter-option-high").click();
expect(onPriorityFilterChange).toHaveBeenCalledWith("high");
});
});