feat(issues): surface cancelled issues via status filter (MUL-4261)

Cancelled issues were never visible in the web/desktop issue surface:
`PAGINATED_STATUSES`/`BOARD_STATUSES` excluded `cancelled`, so the list/
board/swimlane never fetched or rendered it, and the status filter offered
a "Cancelled" checkbox that resolved to an empty list.

Implement plan A (fetch-always, hide-by-default):

- `PAGINATED_STATUSES` now includes `cancelled`, so it is always fetched
  into the byStatus cache and rebuckets correctly when an issue is
  cancelled (previously the card was dropped). `BOARD_STATUSES` stays the
  default *visible* column set.
- The surface gates the flattened list on the status filter: cancelled
  issues are excluded from `surfaceIssues` (and therefore list/board/
  swimlane columns, header facet counts, batch selection, and isEmpty)
  unless the filter explicitly selects "cancelled". Then a Cancelled
  section appears, sorted last.
- `hiddenStatuses` stays board-only, so cancelled is never offered as a
  hideable/persistent board column.

Dragging a card into the Cancelled column (visible only when filtered)
sets status=cancelled through the existing generic column DnD — no new
entry point or copy added.

Non-goals (unchanged): mobile, member/agent archive surfaces, an
always-on cancelled column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-07-08 18:53:20 +08:00
parent c05b67ae4a
commit 0a13877d2e
5 changed files with 178 additions and 13 deletions

View File

@@ -94,6 +94,24 @@ describe("patchIssueInBuckets — cross-status move", () => {
expect(next.byStatus.todo?.total).toBe(0);
expect(next.byStatus.in_progress?.total).toBe(2);
});
// MUL-4261: `cancelled` is now a first-class paginated bucket, so cancelling
// an issue rebuckets it into `cancelled` (instead of dropping it) and the
// rebucketed card stays locatable for later patches.
it("rebuckets a cancelled issue and keeps it locatable", () => {
const c0 = cache({
todo: { issues: [mk("a", "todo", 1)], total: 1 },
cancelled: { issues: [], total: 0 },
});
const cancelled = patchIssueInBuckets(c0, "a", { status: "cancelled" });
expect(ids(cancelled, "todo")).toEqual([]);
expect(ids(cancelled, "cancelled")).toEqual(["a"]);
expect(cancelled.byStatus.cancelled?.total).toBe(1);
// A follow-up edit still finds the card in the cancelled bucket.
const renamed = patchIssueInBuckets(cancelled, "a", { title: "renamed" });
expect(renamed.byStatus.cancelled?.issues[0]?.title).toBe("renamed");
});
});
describe("patchIssueInBuckets — same status", () => {

View File

@@ -131,8 +131,18 @@ export type AssigneeGroupedIssuesFilter = Omit<
/** Page size per status column. */
export const ISSUE_PAGE_SIZE = 50;
/** Statuses the issues/my-issues pages paginate. Cancelled is intentionally excluded — it has never been surfaced in the list/board views. */
export const PAGINATED_STATUSES: readonly IssueStatus[] = BOARD_STATUSES;
/**
* Statuses fetched and paginated into the list/board cache. `cancelled` is
* included so cancelled issues always live in the cache (and rebucket
* correctly when an issue is cancelled); the surface hides them by default and
* only renders a Cancelled section when the status filter explicitly selects
* it. `BOARD_STATUSES` stays the default *visible* column set — this constant
* governs fetch/cache membership, not what the board shows.
*/
export const PAGINATED_STATUSES: readonly IssueStatus[] = [
...BOARD_STATUSES,
"cancelled",
];
/** Flatten a bucketed response to a single Issue[] for consumers that want the whole list. */
export function flattenIssueBuckets(data: ListIssuesCache) {

View File

@@ -15,11 +15,40 @@ import {
import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context";
import type {
AgentTask,
Issue,
IssueStatus,
ListIssuesParams,
ListIssuesResponse,
} from "@multica/core/types";
import { useIssueSurfaceController } from "./use-issue-surface-controller";
function makeIssue(
overrides: Partial<Issue> & Pick<Issue, "id" | "status">,
): Issue {
return {
workspace_id: "ws-1",
number: 1,
identifier: "MUL-1",
title: overrides.id,
description: null,
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: "p1",
position: 1,
stage: null,
start_date: null,
due_date: null,
metadata: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
...overrides,
};
}
const updateIssueMutate = vi.hoisted(() => vi.fn());
const batchUpdateMutateAsync = vi.hoisted(() => vi.fn());
const batchDeleteMutateAsync = vi.hoisted(() => vi.fn());
@@ -451,4 +480,88 @@ describe("useIssueSurfaceController", () => {
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.isEmpty).toBe(true);
});
// --- cancelled visibility (MUL-4261) ---------------------------------
// Cancelled issues are always fetched into the cache, but the surface hides
// them unless the status filter explicitly selects "cancelled".
function mockListByStatus(byStatus: Partial<Record<IssueStatus, Issue[]>>) {
listIssues.mockImplementation((params?: ListIssuesParams) => {
const status = params?.status as IssueStatus | undefined;
const issues = (status && byStatus[status]) ?? [];
return Promise.resolve({ issues, total: issues.length });
});
}
it("always fetches the cancelled bucket even though it is hidden by default", async () => {
const { result } = renderHook(
() =>
useIssueSurfaceController({
scope: { type: "workspace", actorKind: "all" },
modes: ["list"],
}),
{ wrapper: makeWrapper(qc, "workspace:all") },
);
await waitFor(() => expect(listIssues).toHaveBeenCalled());
// The fetch layer requests the cancelled status page like any other.
expect(listIssues).toHaveBeenCalledWith(
expect.objectContaining({ status: "cancelled", limit: 50, offset: 0 }),
);
// …but with no status filter, cancelled is not a visible column.
expect(result.current.visibleStatuses).not.toContain("cancelled");
});
it("keeps cancelled issues out of the default surface and visible statuses", async () => {
mockListByStatus({
todo: [makeIssue({ id: "todo-1", status: "todo" })],
cancelled: [makeIssue({ id: "cancelled-1", status: "cancelled" })],
});
const { result } = renderHook(
() =>
useIssueSurfaceController({
scope: { type: "project", projectId: "p1" },
modes: ["list"],
}),
{ wrapper: makeWrapper(qc, "project:p1") },
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.visibleStatuses).not.toContain("cancelled");
const surfaceIds = result.current.surfaceIssues.map((i) => i.id);
expect(surfaceIds).toContain("todo-1");
expect(surfaceIds).not.toContain("cancelled-1");
expect(result.current.issues.map((i) => i.id)).not.toContain("cancelled-1");
});
it("reveals cancelled issues only when the status filter selects cancelled", async () => {
mockListByStatus({
todo: [makeIssue({ id: "todo-1", status: "todo" })],
cancelled: [makeIssue({ id: "cancelled-1", status: "cancelled" })],
});
const store = getIssueSurfaceViewStore("project:p1");
act(() => store.getState().toggleStatusFilter("cancelled"));
const { result } = renderHook(
() =>
useIssueSurfaceController({
scope: { type: "project", projectId: "p1" },
modes: ["list"],
}),
{ wrapper: makeWrapper(qc, "project:p1") },
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
// Cancelled becomes the sole visible column, sorted last in ALL_STATUSES.
expect(result.current.visibleStatuses).toEqual(["cancelled"]);
expect(result.current.issues.map((i) => i.id)).toEqual(["cancelled-1"]);
expect(result.current.surfaceIssues.map((i) => i.id)).toContain(
"cancelled-1",
);
});
});

View File

@@ -2,9 +2,13 @@
import { useEffect, useMemo } from "react";
import type { QueryKey } from "@tanstack/react-query";
import type { Issue, IssueAssigneeGroup, Project } from "@multica/core/types";
import type {
Issue,
IssueAssigneeGroup,
IssueStatus,
Project,
} from "@multica/core/types";
import { useWorkspaceId } from "@multica/core/hooks";
import { BOARD_STATUSES } from "@multica/core/issues/config";
import { dateOnlyToLocalDate } from "@multica/core/issues/date";
import type {
AssigneeGroupedIssuesFilter,
@@ -59,8 +63,8 @@ export interface IssueSurfaceController {
loadMoreFilter?: MyIssuesFilter;
sort: IssueSortParam;
ganttIssues: Issue[];
visibleStatuses: typeof BOARD_STATUSES;
hiddenStatuses: typeof BOARD_STATUSES;
visibleStatuses: IssueStatus[];
hiddenStatuses: IssueStatus[];
activeFilters: Omit<IssueFilters, "statusFilters" | "runningIssueIds">;
activity: IssueSurfaceActivity;
actions: IssueSurfaceActions;

View File

@@ -3,7 +3,7 @@
import { useMemo } from "react";
import { useQuery, type QueryKey } from "@tanstack/react-query";
import type { Issue, IssueAssigneeGroup, Project } from "@multica/core/types";
import { BOARD_STATUSES } from "@multica/core/issues/config";
import { ALL_STATUSES, BOARD_STATUSES } from "@multica/core/issues/config";
import { projectListOptions } from "@multica/core/projects/queries";
import {
childIssueProgressOptions,
@@ -47,8 +47,8 @@ export interface IssueSurfaceData {
loadMoreScope?: string;
loadMoreFilter?: MyIssuesFilter;
ganttIssues: Issue[];
visibleStatuses: typeof BOARD_STATUSES;
hiddenStatuses: typeof BOARD_STATUSES;
visibleStatuses: IssueStatus[];
hiddenStatuses: IssueStatus[];
activeFilters: Omit<IssueFilters, "statusFilters" | "runningIssueIds">;
activity: IssueSurfaceActivity;
childProgressMap: Map<string, ChildProgress>;
@@ -156,8 +156,22 @@ export function useIssueSurfaceData({
: (statusIssuesQuery.data ?? EMPTY_ISSUES);
}, [assigneeGroupsQuery.data?.groups, statusIssuesQuery.data, usesAssigneeBoard]);
// Cancelled issues are always fetched into the cache (PAGINATED_STATUSES),
// but stay hidden until the status filter explicitly selects "cancelled".
// Gating the flattened list here means every downstream consumer — list /
// board / swimlane columns, header facet counts, batch selection, and the
// isEmpty check — excludes cancelled by default with no per-view branching.
const showCancelled = statusFilters.includes("cancelled");
const visibleBucketedIssues = useMemo(
() =>
showCancelled
? bucketedIssues
: bucketedIssues.filter((issue) => issue.status !== "cancelled"),
[bucketedIssues, showCancelled],
);
const ganttIssues = ganttIssuesQuery.data ?? EMPTY_ISSUES;
const surfaceIssues = usesGantt ? ganttIssues : bucketedIssues;
const surfaceIssues = usesGantt ? ganttIssues : visibleBucketedIssues;
const baseFilterState = useMemo<IssueFilterState>(
() => ({
@@ -239,14 +253,20 @@ export function useIssueSurfaceData({
[projects],
);
const visibleStatuses = useMemo(() => {
const visibleStatuses = useMemo<IssueStatus[]>(() => {
if (statusFilters.length > 0) {
return BOARD_STATUSES.filter((s) => statusFilters.includes(s));
// ALL_STATUSES keeps canonical order and places `cancelled` last, so a
// Cancelled section appears (only) when the filter explicitly selects it.
return ALL_STATUSES.filter((s) => statusFilters.includes(s));
}
// Default view: the six board statuses, cancelled excluded.
return BOARD_STATUSES;
}, [statusFilters]);
const hiddenStatuses = useMemo(
// Hidden columns come from the board set only, so `cancelled` is never
// offered as a hideable/always-on board column (it is filter-gated, not a
// persistent column).
const hiddenStatuses = useMemo<IssueStatus[]>(
() => BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s)),
[visibleStatuses],
);