diff --git a/packages/core/issues/cache-helpers.test.ts b/packages/core/issues/cache-helpers.test.ts index bac1fecd23..c09f272411 100644 --- a/packages/core/issues/cache-helpers.test.ts +++ b/packages/core/issues/cache-helpers.test.ts @@ -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", () => { diff --git a/packages/core/issues/queries.ts b/packages/core/issues/queries.ts index a4123beb96..f8e20cf795 100644 --- a/packages/core/issues/queries.ts +++ b/packages/core/issues/queries.ts @@ -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) { diff --git a/packages/views/issues/surface/use-issue-surface-controller.test.tsx b/packages/views/issues/surface/use-issue-surface-controller.test.tsx index 39de80cdc7..8b33c336cf 100644 --- a/packages/views/issues/surface/use-issue-surface-controller.test.tsx +++ b/packages/views/issues/surface/use-issue-surface-controller.test.tsx @@ -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 & Pick, +): 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>) { + 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", + ); + }); }); diff --git a/packages/views/issues/surface/use-issue-surface-controller.ts b/packages/views/issues/surface/use-issue-surface-controller.ts index 4c66a256fe..f43e8d7654 100644 --- a/packages/views/issues/surface/use-issue-surface-controller.ts +++ b/packages/views/issues/surface/use-issue-surface-controller.ts @@ -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; activity: IssueSurfaceActivity; actions: IssueSurfaceActions; diff --git a/packages/views/issues/surface/use-issue-surface-data.ts b/packages/views/issues/surface/use-issue-surface-data.ts index 622516a206..d2b1b39ba9 100644 --- a/packages/views/issues/surface/use-issue-surface-data.ts +++ b/packages/views/issues/surface/use-issue-surface-data.ts @@ -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; activity: IssueSurfaceActivity; childProgressMap: Map; @@ -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( () => ({ @@ -239,14 +253,20 @@ export function useIssueSurfaceData({ [projects], ); - const visibleStatuses = useMemo(() => { + const visibleStatuses = useMemo(() => { 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( () => BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s)), [visibleStatuses], );