mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user