mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-01 17:37:37 +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> * fix(issues): swimlane must keep the cancelled column when filtered (MUL-4261) The swimlane derived its status columns as `BOARD_STATUSES.filter(s => visibleStatuses.includes(s))`, re-imposing canonical order by intersecting with BOARD_STATUSES. Since BOARD_STATUSES omits `cancelled`, a filter-selected Cancelled column was silently dropped even though the controller's `visibleStatuses` included it — the surface fetched and gated cancelled correctly, but swimlane never rendered it. Filter against ALL_STATUSES instead: same canonical ordering, but a selected `cancelled` column now survives. `hiddenStatuses` stays board-only, so cancelled is still never a hideable/persistent column. Regression tests: - swimlane renders a Cancelled column + its cards when cancelled is in visibleStatuses, and omits it otherwise (verified failing pre-fix); - controller asserts hiddenStatuses never contains cancelled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
150 lines
4.8 KiB
TypeScript
150 lines
4.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { Issue, ListIssuesCache } from "../types";
|
|
import { insertByPosition, patchIssueInBuckets } from "./cache-helpers";
|
|
|
|
const WS_ID = "ws-1";
|
|
|
|
function mk(id: string, status: Issue["status"], position: number): Issue {
|
|
return {
|
|
id,
|
|
workspace_id: WS_ID,
|
|
number: 1,
|
|
identifier: `MUL-${id}`,
|
|
title: id,
|
|
description: null,
|
|
status,
|
|
priority: "none",
|
|
assignee_type: null,
|
|
assignee_id: null,
|
|
creator_type: "member",
|
|
creator_id: "user-1",
|
|
parent_issue_id: null,
|
|
project_id: null,
|
|
position,
|
|
stage: null,
|
|
start_date: null,
|
|
due_date: null,
|
|
metadata: {},
|
|
labels: [],
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at: "2025-01-01T00:00:00Z",
|
|
};
|
|
}
|
|
|
|
function cache(byStatus: ListIssuesCache["byStatus"]): ListIssuesCache {
|
|
return { byStatus };
|
|
}
|
|
|
|
function ids(c: ListIssuesCache, status: Issue["status"]): string[] {
|
|
return (c.byStatus[status]?.issues ?? []).map((i) => i.id);
|
|
}
|
|
|
|
describe("insertByPosition", () => {
|
|
it("inserts at the position-sorted slot", () => {
|
|
const a = mk("a", "todo", 1);
|
|
const c = mk("c", "todo", 3);
|
|
const b = mk("b", "todo", 2);
|
|
expect(insertByPosition([a, c], b).map((i) => i.id)).toEqual([
|
|
"a",
|
|
"b",
|
|
"c",
|
|
]);
|
|
});
|
|
|
|
it("appends when the new position is the largest", () => {
|
|
const a = mk("a", "todo", 1);
|
|
const z = mk("z", "todo", 9);
|
|
expect(insertByPosition([a], z).map((i) => i.id)).toEqual(["a", "z"]);
|
|
});
|
|
|
|
it("prepends when the new position is the smallest", () => {
|
|
const b = mk("b", "todo", 2);
|
|
const a = mk("a", "todo", 1);
|
|
expect(insertByPosition([b], a).map((i) => i.id)).toEqual(["a", "b"]);
|
|
});
|
|
});
|
|
|
|
describe("patchIssueInBuckets — cross-status move", () => {
|
|
it("inserts the moved card at its position slot, not the end", () => {
|
|
const c0 = cache({
|
|
todo: { issues: [mk("moved", "todo", 5)], total: 1 },
|
|
in_progress: {
|
|
issues: [mk("x", "in_progress", 1), mk("y", "in_progress", 3)],
|
|
total: 2,
|
|
},
|
|
});
|
|
// Move "moved" into in_progress at position 2 (between x and y).
|
|
const next = patchIssueInBuckets(c0, "moved", {
|
|
status: "in_progress",
|
|
position: 2,
|
|
});
|
|
expect(ids(next, "in_progress")).toEqual(["x", "moved", "y"]);
|
|
expect(ids(next, "todo")).toEqual([]);
|
|
});
|
|
|
|
it("adjusts both bucket totals", () => {
|
|
const c0 = cache({
|
|
todo: { issues: [mk("moved", "todo", 5)], total: 1 },
|
|
in_progress: { issues: [mk("x", "in_progress", 1)], total: 1 },
|
|
});
|
|
const next = patchIssueInBuckets(c0, "moved", {
|
|
status: "in_progress",
|
|
position: 2,
|
|
});
|
|
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", () => {
|
|
it("keeps the slot for a plain field update (no reorder)", () => {
|
|
const c0 = cache({
|
|
todo: {
|
|
issues: [mk("a", "todo", 1), mk("b", "todo", 2), mk("c", "todo", 3)],
|
|
total: 3,
|
|
},
|
|
});
|
|
// A remote label/title edit must not move the card.
|
|
const next = patchIssueInBuckets(c0, "b", { title: "renamed" });
|
|
expect(ids(next, "todo")).toEqual(["a", "b", "c"]);
|
|
expect(next.byStatus.todo?.issues[1]?.title).toBe("renamed");
|
|
});
|
|
|
|
it("re-sorts within the column when position changes", () => {
|
|
const c0 = cache({
|
|
todo: {
|
|
issues: [mk("a", "todo", 1), mk("b", "todo", 2), mk("c", "todo", 3)],
|
|
total: 3,
|
|
},
|
|
});
|
|
// Drag "a" below "b" (new position 2.5).
|
|
const next = patchIssueInBuckets(c0, "a", { position: 2.5 });
|
|
expect(ids(next, "todo")).toEqual(["b", "a", "c"]);
|
|
});
|
|
});
|
|
|
|
describe("patchIssueInBuckets — unknown issue", () => {
|
|
it("returns the cache unchanged when the id is absent", () => {
|
|
const c0 = cache({ todo: { issues: [mk("a", "todo", 1)], total: 1 } });
|
|
expect(patchIssueInBuckets(c0, "ghost", { position: 9 })).toBe(c0);
|
|
});
|
|
});
|