Files
multica/packages/views/issues/components/issues-page.test.tsx
Bohan Jiang 0236e409e4 feat(issues): client-side label filter on the issues list (#1782)
Adds a Label submenu to the workspace issues filter dropdown, backed by
labelFilters in the shared issue view store. The filter is OR'd within
itself (issue matches if it carries any of the selected labels) and
AND'd with the existing status / priority / assignee / creator /
project dimensions, mirroring the multi-select semantics already in
place. Each label row renders via LabelChip for color parity with the
sidebar picker, and each row's count comes from the same
useIssueCounts pass that drives the other filter chips.

Filtering stays client-side, consistent with all other filters today.
The pagination caveat is a known limitation we'll revisit if real
workspaces start hitting it; this PR intentionally does not change the
fetch path.
2026-04-28 16:47:33 +08:00

428 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@multica/core/types";
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
// Mock @multica/core/auth
const mockAuthUser = { id: "user-1", email: "test@test.com", name: "Test User" };
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(
(selector?: any) => {
const state = { user: mockAuthUser, isAuthenticated: true };
return selector ? selector(state) : state;
},
{ getState: () => ({ user: mockAuthUser, isAuthenticated: true }) },
),
registerAuthStore: vi.fn(),
createAuthStore: vi.fn(),
}));
// Mock @multica/core/paths — after the URL-driven workspace refactor,
// useCurrentWorkspace derives from the workspace slug in URL Context. Tests
// don't mount a real route, so we short-circuit to a fixed fixture.
vi.mock("@multica/core/paths", async () => {
const actual = await vi.importActual<typeof import("@multica/core/paths")>(
"@multica/core/paths",
);
return {
...actual,
useCurrentWorkspace: () => ({ id: "ws-1", name: "Test WS", slug: "test" }),
useWorkspacePaths: () => actual.paths.workspace("test"),
};
});
// Mock @multica/views/navigation (AppLink + useNavigation)
vi.mock("../../navigation", () => ({
AppLink: ({ children, href, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
useNavigation: () => ({ push: vi.fn(), pathname: "/issues" }),
NavigationProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock workspace avatar
vi.mock("../../workspace/workspace-avatar", () => ({
WorkspaceAvatar: ({ name }: { name: string }) => <span data-testid="workspace-avatar">{name.charAt(0)}</span>,
}));
// Mock api (queries use api internally)
const mockListIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ issues: [], total: 0 }));
vi.mock("@multica/core/api", () => ({
api: {
listIssues: (...args: any[]) => mockListIssues(...args),
updateIssue: vi.fn(),
listMembers: () => Promise.resolve([]),
listAgents: () => Promise.resolve([]),
},
getApi: () => ({
listIssues: (...args: any[]) => mockListIssues(...args),
updateIssue: vi.fn(),
listMembers: () => Promise.resolve([]),
listAgents: () => Promise.resolve([]),
}),
setApiInstance: vi.fn(),
}));
// Mock issue config
vi.mock("@multica/core/issues/config", () => ({
ALL_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
BOARD_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked"],
STATUS_ORDER: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
STATUS_CONFIG: {
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" },
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
},
PRIORITY_ORDER: ["urgent", "high", "medium", "low", "none"],
PRIORITY_CONFIG: {
urgent: { label: "Urgent", bars: 4, color: "text-destructive" },
high: { label: "High", bars: 3, color: "text-warning" },
medium: { label: "Medium", bars: 2, color: "text-warning" },
low: { label: "Low", bars: 1, color: "text-info" },
none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
},
}));
// Mock view store
const mockViewState = {
viewMode: "board" as const,
statusFilters: [] as string[],
priorityFilters: [] as string[],
assigneeFilters: [] as { type: string; id: string }[],
includeNoAssignee: false,
creatorFilters: [] as { type: string; id: string }[],
projectFilters: [] as string[],
includeNoProject: false,
labelFilters: [] as string[],
sortBy: "position" as const,
sortDirection: "asc" as const,
cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true },
listCollapsedStatuses: [] as string[],
setViewMode: vi.fn(),
toggleStatusFilter: vi.fn(),
togglePriorityFilter: vi.fn(),
toggleAssigneeFilter: vi.fn(),
toggleNoAssignee: vi.fn(),
toggleCreatorFilter: vi.fn(),
toggleProjectFilter: vi.fn(),
toggleNoProject: vi.fn(),
toggleLabelFilter: vi.fn(),
hideStatus: vi.fn(),
showStatus: vi.fn(),
clearFilters: vi.fn(),
setSortBy: vi.fn(),
setSortDirection: vi.fn(),
toggleCardProperty: vi.fn(),
toggleListCollapsed: vi.fn(),
};
vi.mock("@multica/core/issues/stores/view-store", () => ({
useClearFiltersOnWorkspaceChange: () => {},
viewStorePersistOptions: () => ({ name: "test", storage: undefined, partialize: (s: any) => s }),
mergeViewStatePersisted: (_p: unknown, c: any) => c,
viewStoreSlice: vi.fn(),
useIssueViewStore: Object.assign(
(selector?: any) => (selector ? selector(mockViewState) : mockViewState),
{ getState: () => mockViewState, setState: vi.fn() },
),
createIssueViewStore: () => ({
getState: () => mockViewState,
setState: vi.fn(),
subscribe: vi.fn(),
}),
SORT_OPTIONS: [
{ value: "position", label: "Manual" },
{ value: "priority", label: "Priority" },
{ value: "due_date", label: "Due date" },
{ value: "created_at", label: "Created date" },
{ value: "title", label: "Title" },
],
CARD_PROPERTY_OPTIONS: [
{ key: "priority", label: "Priority" },
{ key: "description", label: "Description" },
{ key: "assignee", label: "Assignee" },
{ key: "dueDate", label: "Due date" },
{ key: "project", label: "Project" },
{ key: "labels", label: "Labels" },
{ key: "childProgress", label: "Sub-issue progress" },
],
}));
vi.mock("@multica/core/issues/stores/view-store-context", () => ({
ViewStoreProvider: ({ children }: { children: React.ReactNode }) => children,
useViewStore: (selector?: any) => (selector ? selector(mockViewState) : mockViewState),
useViewStoreApi: () => ({ getState: () => mockViewState, setState: vi.fn(), subscribe: vi.fn() }),
}));
vi.mock("@multica/core/issues/stores/issues-scope-store", () => ({
useIssuesScopeStore: Object.assign(
(selector?: any) => {
const state = { scope: "all", setScope: vi.fn() };
return selector ? selector(state) : state;
},
{ getState: () => ({ scope: "all", setScope: vi.fn() }) },
),
}));
vi.mock("@multica/core/issues/stores/selection-store", () => ({
useIssueSelectionStore: Object.assign(
(selector?: any) => {
const state = { selectedIds: new Set(), toggle: vi.fn(), clear: vi.fn(), setAll: vi.fn() };
return selector ? selector(state) : state;
},
{ getState: () => ({ selectedIds: new Set(), toggle: vi.fn(), clear: vi.fn(), setAll: vi.fn() }) },
),
}));
vi.mock("@multica/core/issues/stores/recent-issues-store", () => ({
useRecentIssuesStore: Object.assign(
(selector?: any) => {
const state = { items: [], recordVisit: vi.fn() };
return selector ? selector(state) : state;
},
{ getState: () => ({ items: [], recordVisit: vi.fn() }) },
),
}));
vi.mock("@multica/core/modals", () => ({
useModalStore: Object.assign(
() => ({ open: vi.fn() }),
{ getState: () => ({ open: vi.fn() }) },
),
}));
// Mock sonner toast
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
// Mock dnd-kit
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: any) => children,
DragOverlay: () => null,
PointerSensor: class {},
useSensor: () => ({}),
useSensors: () => [],
useDroppable: () => ({ setNodeRef: vi.fn(), isOver: false }),
pointerWithin: vi.fn(),
closestCenter: vi.fn(),
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: any) => children,
verticalListSortingStrategy: {},
arrayMove: vi.fn(),
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: null,
isDragging: false,
}),
}));
vi.mock("@dnd-kit/utilities", () => ({
CSS: { Transform: { toString: () => undefined } },
}));
// Mock @base-ui/react/accordion (used by ListView)
vi.mock("@base-ui/react/accordion", () => ({
Accordion: Object.assign(
({ children }: any) => <div>{children}</div>,
{
Root: ({ children }: any) => <div>{children}</div>,
Item: ({ children }: any) => <div>{children}</div>,
Header: ({ children }: any) => <div>{children}</div>,
Trigger: ({ children }: any) => <button>{children}</button>,
Panel: ({ children }: any) => <div>{children}</div>,
},
),
}));
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
const issueDefaults = {
parent_issue_id: null,
project_id: null,
position: 0,
};
const mockIssues: Issue[] = [
{
...issueDefaults,
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "TES-1",
title: "Implement auth",
description: "Add JWT authentication",
status: "todo",
priority: "high",
assignee_type: "member",
assignee_id: "user-1",
creator_type: "member",
creator_id: "user-1",
due_date: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
{
...issueDefaults,
id: "issue-2",
workspace_id: "ws-1",
number: 2,
identifier: "TES-2",
title: "Design landing page",
description: null,
status: "in_progress",
priority: "medium",
assignee_type: "agent",
assignee_id: "agent-1",
creator_type: "member",
creator_id: "user-1",
due_date: "2026-02-01T00:00:00Z",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
{
...issueDefaults,
id: "issue-3",
workspace_id: "ws-1",
number: 3,
identifier: "TES-3",
title: "Write tests",
description: null,
status: "backlog",
priority: "low",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
due_date: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
];
// ---------------------------------------------------------------------------
// Import component under test (after mocks)
// ---------------------------------------------------------------------------
import { IssuesPage } from "./issues-page";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderWithQuery(ui: React.ReactElement) {
const qc = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={qc}>
{ui}
</QueryClientProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("IssuesPage (shared)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockListIssues.mockResolvedValue({ issues: [], total: 0 });
mockViewState.viewMode = "board";
mockViewState.statusFilters = [];
mockViewState.priorityFilters = [];
});
it("shows loading skeletons initially", () => {
renderWithQuery(<IssuesPage />);
expect(
screen.getAllByRole("generic").some((el) => el.getAttribute("data-slot") === "skeleton"),
).toBe(true);
});
it("renders issue titles after data loads", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve({
issues: mockIssues.filter((i) => i.status === params?.status),
total: mockIssues.filter((i) => i.status === params?.status).length,
}),
);
renderWithQuery(<IssuesPage />);
await screen.findByText("Implement auth");
expect(screen.getByText("Design landing page")).toBeInTheDocument();
expect(screen.getByText("Write tests")).toBeInTheDocument();
});
it("renders board column headers", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve({
issues: mockIssues.filter((i) => i.status === params?.status),
total: mockIssues.filter((i) => i.status === params?.status).length,
}),
);
renderWithQuery(<IssuesPage />);
await screen.findByText("Backlog");
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1);
});
it("shows workspace breadcrumb with 'Issues' label", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve({
issues: mockIssues.filter((i) => i.status === params?.status),
total: mockIssues.filter((i) => i.status === params?.status).length,
}),
);
renderWithQuery(<IssuesPage />);
await screen.findByText("Issues");
expect(screen.getByText("Test WS")).toBeInTheDocument();
});
it("shows empty state when there are no issues", async () => {
mockListIssues.mockResolvedValue({ issues: [], total: 0 });
renderWithQuery(<IssuesPage />);
await screen.findByText("No issues yet");
expect(screen.getByText("Create an issue to get started.")).toBeInTheDocument();
});
it("shows scope tab buttons", async () => {
renderWithQuery(<IssuesPage />);
await screen.findByText("All");
expect(screen.getByText("Members")).toBeInTheDocument();
expect(screen.getByText("Agents")).toBeInTheDocument();
});
});