Files
multica/packages/views/issues/components/issues-page.test.tsx
Naiyuan Qing 0582db356e feat(issues): make cancelled a default status, not filter-gated (MUL-4290) (#5135)
MUL-4261 surfaced cancelled issues only when the status filter explicitly
selected "cancelled": a separate BOARD_STATUSES (six statuses, cancelled
excluded) plus a runtime showCancelled gate hid cancelled from the default
list/board/swimlane. That is the wrong product model — cancelled is a
lifecycle state in the same category as todo/in_progress/done/blocked and
should be a first-class default column.

- Remove BOARD_STATUSES. Its only purpose was to exclude cancelled, which
  this change reverses. PAGINATED_STATUSES is now ALL_STATUSES; the surface's
  default visible/hidden status derivation, the assignee-grouped board's
  default status set, and the swimlane column fallback all use ALL_STATUSES.
- Remove the `bucketedIssues.filter(status !== "cancelled")` gate in the
  surface data layer. Cancelled flows through to list/board/swimlane columns,
  header facet counts, batch selection, and isEmpty like every other status.
- hiddenStatuses derives from ALL_STATUSES, so cancelled participates in the
  board show/hide controls consistently (hideStatus already used ALL_STATUSES).

The status filter now narrows the visible set instead of unlocking an
otherwise-hidden bucket. Cancelled renders last (its canonical ALL_STATUSES
position). Mobile keeps its own status mirror and is out of scope.

Regression tests updated: controller now asserts cancelled is a default
visible status, the filter narrows (and can hide cancelled), swimlane renders
the Cancelled column by default and drops it only when the filter narrows past
it, and the assignee board fetches cancelled by default.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 11:10:24 +08:00

641 lines
21 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";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
import enIssues from "../../locales/en/issues.json";
const TEST_RESOURCES = { en: { common: enCommon, issues: enIssues } };
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 }));
const mockListGroupedIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ groups: [] }));
const mockListMembers = vi.hoisted(() =>
vi.fn().mockResolvedValue([
{
id: "member-1",
workspace_id: "ws-1",
user_id: "user-1",
role: "member",
created_at: "2026-01-01T00:00:00Z",
name: "Test User",
email: "test@test.com",
avatar_url: null,
},
]),
);
const mockListAgents = vi.hoisted(() =>
vi.fn().mockResolvedValue([
{
id: "agent-1",
workspace_id: "ws-1",
name: "Agent One",
description: "",
instructions: "",
status: "idle",
runtime_id: null,
owner_id: "user-1",
avatar_url: null,
visibility: "workspace",
archived_at: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
]),
);
const mockListSquads = vi.hoisted(() =>
vi.fn().mockResolvedValue([
{
id: "squad-1",
workspace_id: "ws-1",
name: "Squad One",
description: "",
instructions: "",
avatar_url: null,
leader_id: "agent-1",
creator_id: "user-1",
archived_at: null,
archived_by: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
]),
);
vi.mock("@multica/core/api", () => ({
api: {
getBaseUrl: () => "http://127.0.0.1:8080",
listIssues: (...args: any[]) => mockListIssues(...args),
listGroupedIssues: (...args: any[]) => mockListGroupedIssues(...args),
updateIssue: vi.fn(),
listMembers: (...args: any[]) => mockListMembers(...args),
listAgents: (...args: any[]) => mockListAgents(...args),
listSquads: (...args: any[]) => mockListSquads(...args),
},
getApi: () => ({
listIssues: (...args: any[]) => mockListIssues(...args),
listGroupedIssues: (...args: any[]) => mockListGroupedIssues(...args),
updateIssue: vi.fn(),
listMembers: (...args: any[]) => mockListMembers(...args),
listAgents: (...args: any[]) => mockListAgents(...args),
listSquads: (...args: any[]) => mockListSquads(...args),
}),
setApiInstance: vi.fn(),
}));
// Mock issue config
vi.mock("@multica/core/issues/config", () => ({
ALL_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
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 "board" | "list",
grouping: "status" as "status" | "assignee",
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(),
setGrouping: 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" },
],
GROUPING_OPTIONS: [
{ value: "status", label: "Status" },
{ value: "assignee", label: "Assignee" },
],
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() }),
}));
let mockScope = "all";
vi.mock("@multica/core/issues/stores/issues-scope-store", () => ({
useIssuesScopeStore: Object.assign(
(selector?: any) => {
const state = { scope: mockScope, setScope: vi.fn() };
return selector ? selector(state) : state;
},
{ getState: () => ({ scope: mockScope, 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 = { byWorkspace: {}, recordVisit: vi.fn(), pruneWorkspaces: vi.fn() };
return selector ? selector(state) : state;
},
{
getState: () => ({
byWorkspace: {},
recordVisit: vi.fn(),
pruneWorkspaces: vi.fn(),
}),
},
),
selectRecentIssues: () => () => [],
}));
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,
stage: null,
metadata: {},
};
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",
start_date: null,
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",
start_date: null,
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",
start_date: null,
due_date: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
{
...issueDefaults,
id: "issue-4",
workspace_id: "ws-1",
number: 4,
identifier: "TES-4",
title: "Squad task",
description: null,
status: "todo",
priority: "medium",
assignee_type: "squad",
assignee_id: "squad-1",
creator_type: "member",
creator_id: "user-1",
start_date: null,
due_date: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
];
function mockAssigneeGroups(issues: Issue[]) {
const groups = new Map<string, { assignee_type: Issue["assignee_type"]; assignee_id: string | null; issues: Issue[] }>();
for (const issue of issues) {
const id =
issue.assignee_type && issue.assignee_id
? `assignee:${issue.assignee_type}:${issue.assignee_id}`
: "assignee:unassigned";
if (!groups.has(id)) {
groups.set(id, {
assignee_type: issue.assignee_type,
assignee_id: issue.assignee_id,
issues: [],
});
}
groups.get(id)!.issues.push(issue);
}
return {
groups: [...groups.entries()].map(([id, group]) => ({
id,
assignee_type: group.assignee_type,
assignee_id: group.assignee_id,
issues: group.issues,
total: group.issues.length,
})),
};
}
// ---------------------------------------------------------------------------
// 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(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={qc}>
{ui}
</QueryClientProvider>
</I18nProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("IssuesPage (shared)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockListIssues.mockResolvedValue({ issues: [], total: 0 });
mockListGroupedIssues.mockResolvedValue({ groups: [] });
mockViewState.viewMode = "board";
mockViewState.grouping = "status";
mockViewState.statusFilters = [];
mockViewState.priorityFilters = [];
mockScope = "all";
});
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("groups board columns by assignee", async () => {
mockViewState.grouping = "assignee";
mockListGroupedIssues.mockResolvedValue(mockAssigneeGroups(mockIssues));
renderWithQuery(<IssuesPage />);
// "Test User" renders both as the assignee group header and on the
// assignee chip of each card grouped under that header, so a unique
// match is not guaranteed.
await screen.findAllByText("Test User");
expect(screen.getAllByText("Agent One").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Squad One").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("No assignee")).toBeInTheDocument();
});
it("uses grouped assignee endpoint instead of status page sweep", async () => {
mockViewState.grouping = "assignee";
mockListGroupedIssues.mockResolvedValue(mockAssigneeGroups(mockIssues));
renderWithQuery(<IssuesPage />);
await screen.findByText("Implement auth");
expect(mockListGroupedIssues).toHaveBeenCalledWith(
expect.objectContaining({
group_by: "assignee",
limit: 50,
offset: 0,
statuses: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
}),
);
expect(mockListIssues).not.toHaveBeenCalled();
});
it("shows the 'Issues' section header without a workspace prefix", 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");
// The list header is now `icon + title`, matching the other list pages.
// The workspace/org name is no longer rendered as a breadcrumb prefix.
expect(screen.queryByText("Test WS")).not.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 />);
expect(await screen.findAllByText("All")).not.toHaveLength(0);
expect(screen.getByText("Members")).toBeInTheDocument();
expect(screen.getByText("Agents")).toBeInTheDocument();
});
// The Members/Agents tabs filter server-side via assignee_types (the same
// param the grouped endpoint takes), so the mock mirrors the server's
// WHERE clause instead of a client-side post-filter.
function mockListIssuesHonoringAssigneeTypes() {
mockListIssues.mockImplementation((params: any) => {
const matches = mockIssues.filter(
(i) =>
i.status === params?.status &&
(!params?.assignee_types ||
(i.assignee_type !== null &&
params.assignee_types.includes(i.assignee_type))),
);
return Promise.resolve({ issues: matches, total: matches.length });
});
}
it("agents scope includes squad-assigned issues", async () => {
mockScope = "agents";
mockViewState.viewMode = "list";
mockListIssuesHonoringAssigneeTypes();
renderWithQuery(<IssuesPage />);
// Squad task and agent task should be visible
await screen.findByText("Design landing page");
expect(screen.getByText("Squad task")).toBeInTheDocument();
// Member task should NOT be visible
expect(screen.queryByText("Implement auth")).not.toBeInTheDocument();
expect(mockListIssues).toHaveBeenCalledWith(
expect.objectContaining({ assignee_types: ["agent", "squad"] }),
);
});
it("members scope excludes squad-assigned issues", async () => {
mockScope = "members";
mockViewState.viewMode = "list";
mockListIssuesHonoringAssigneeTypes();
renderWithQuery(<IssuesPage />);
await screen.findByText("Implement auth");
expect(screen.queryByText("Squad task")).not.toBeInTheDocument();
expect(screen.queryByText("Design landing page")).not.toBeInTheDocument();
expect(mockListIssues).toHaveBeenCalledWith(
expect.objectContaining({ assignee_types: ["member"] }),
);
});
});