Files
multica/packages/views/issues/surface/use-issue-surface-data.ts
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

338 lines
10 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { useQuery, type QueryKey } from "@tanstack/react-query";
import type { Issue, IssueAssigneeGroup, Project } from "@multica/core/types";
import { ALL_STATUSES } from "@multica/core/issues/config";
import { projectListOptions } from "@multica/core/projects/queries";
import {
childIssueProgressOptions,
type AssigneeGroupedIssuesFilter,
type IssueSortParam,
type MyIssuesFilter,
} from "@multica/core/issues/queries";
import {
issueSurfaceAssigneeGroupsOptions,
issueSurfaceGanttOptions,
issueSurfaceListOptions,
} from "@multica/core/issues/surface/repository";
import type { IssueSurfaceQueryPlan } from "@multica/core/issues/surface/query-plan";
import type { IssueStatus } from "@multica/core/types";
import {
applyIssueFilters,
filterAssigneeGroups,
type IssueFilterState,
type IssueFilters,
} from "../utils/filter";
import type { ChildProgress } from "../components/list-row";
import {
useIssueSurfaceActivity,
type IssueSurfaceActivity,
} from "./activity";
const EMPTY_ISSUES: Issue[] = [];
const EMPTY_CHILD_PROGRESS = new Map<string, ChildProgress>();
const EMPTY_PROJECTS: Project[] = [];
export interface IssueSurfaceData {
surfaceIssues: Issue[];
projectIssues: Issue[];
issues: Issue[];
swimlaneIssues: Issue[];
filteredGanttIssues: Issue[];
assigneeGroups?: IssueAssigneeGroup[];
assigneeGroupQueryKey?: QueryKey;
assigneeGroupFilter?: AssigneeGroupedIssuesFilter;
filter: MyIssuesFilter;
loadMoreScope?: string;
loadMoreFilter?: MyIssuesFilter;
ganttIssues: Issue[];
visibleStatuses: IssueStatus[];
hiddenStatuses: IssueStatus[];
activeFilters: Omit<IssueFilters, "statusFilters" | "runningIssueIds">;
activity: IssueSurfaceActivity;
childProgressMap: Map<string, ChildProgress>;
projectMap: Map<string, Project>;
isLoading: boolean;
/** The window's data is being revalidated while the previous snapshot is
* shown as a placeholder (sort/date change, or any grouped-board filter
* change). Drives the header's deferred refresh indicator — content stays
* put, so this is NOT a loading state. */
isRefreshing: boolean;
isEmpty: boolean;
}
export function useIssueSurfaceData({
wsId,
queryPlan,
projectId,
usesAssigneeBoard,
usesGantt,
sort,
statusFilters,
priorityFilters,
assigneeFilters,
includeNoAssignee,
creatorFilters,
projectFilters,
includeNoProject,
labelFilters,
agentRunningFilter,
showSubIssues,
loadProjects,
}: {
wsId: string;
queryPlan: IssueSurfaceQueryPlan;
projectId?: string;
usesAssigneeBoard: boolean;
usesGantt: boolean;
sort: IssueSortParam;
statusFilters: IssueStatus[];
priorityFilters: IssueFilterState["priorityFilters"];
assigneeFilters: IssueFilterState["assigneeFilters"];
includeNoAssignee: boolean;
creatorFilters: IssueFilterState["creatorFilters"];
projectFilters: string[];
includeNoProject: boolean;
labelFilters: string[];
agentRunningFilter: boolean;
showSubIssues: boolean;
loadProjects: boolean;
}): IssueSurfaceData {
const activity = useIssueSurfaceActivity();
const filterContext = useMemo(
() => ({ activityByIssueId: activity.activityByIssueId }),
[activity.activityByIssueId],
);
const assigneeGroupFilter = useMemo<AssigneeGroupedIssuesFilter>(
() => ({
...queryPlan.groupedScopeFilter,
statuses: statusFilters.length > 0 ? statusFilters : [...ALL_STATUSES],
priorities: priorityFilters,
assignee_filters: assigneeFilters,
include_no_assignee: includeNoAssignee,
creator_filters: creatorFilters,
project_ids: projectFilters,
include_no_project: includeNoProject,
label_ids: labelFilters,
}),
[
assigneeFilters,
creatorFilters,
includeNoAssignee,
includeNoProject,
labelFilters,
priorityFilters,
projectFilters,
queryPlan.groupedScopeFilter,
statusFilters,
],
);
const activeAssigneeGroupsOptions = issueSurfaceAssigneeGroupsOptions(
wsId,
queryPlan,
assigneeGroupFilter,
sort,
);
const statusIssuesQuery = useQuery({
...issueSurfaceListOptions(wsId, queryPlan, sort),
enabled: !usesAssigneeBoard && !usesGantt,
});
const assigneeGroupsQuery = useQuery({
...activeAssigneeGroupsOptions,
enabled: usesAssigneeBoard,
});
const ganttIssuesQuery = useQuery({
...issueSurfaceGanttOptions(wsId, projectId ?? ""),
enabled: usesGantt,
});
const bucketedIssues = useMemo(() => {
return usesAssigneeBoard
? (assigneeGroupsQuery.data?.groups.flatMap((group) => group.issues) ?? [])
: (statusIssuesQuery.data ?? EMPTY_ISSUES);
}, [assigneeGroupsQuery.data?.groups, statusIssuesQuery.data, usesAssigneeBoard]);
// `cancelled` is a first-class default status (MUL-4290): it is fetched into
// the cache like every other status and flows straight through to list /
// board / swimlane columns, header facet counts, batch selection, and the
// isEmpty check. The status filter narrows this set like any other status —
// it no longer unlocks an otherwise-hidden bucket.
const ganttIssues = ganttIssuesQuery.data ?? EMPTY_ISSUES;
const surfaceIssues = usesGantt ? ganttIssues : bucketedIssues;
const baseFilterState = useMemo<IssueFilterState>(
() => ({
statusFilters,
priorityFilters,
assigneeFilters,
includeNoAssignee,
creatorFilters,
projectFilters,
includeNoProject,
labelFilters,
workingOnly: agentRunningFilter,
showSubIssues,
}),
[
agentRunningFilter,
assigneeFilters,
creatorFilters,
includeNoAssignee,
includeNoProject,
labelFilters,
priorityFilters,
projectFilters,
showSubIssues,
statusFilters,
],
);
const issues = useMemo(
() => applyIssueFilters(surfaceIssues, baseFilterState, filterContext),
[baseFilterState, filterContext, surfaceIssues],
);
const statuslessFilterState = useMemo<IssueFilterState>(
() => ({
...baseFilterState,
statusFilters: [],
}),
[baseFilterState],
);
const swimlaneIssues = useMemo(
() => applyIssueFilters(surfaceIssues, statuslessFilterState, filterContext),
[filterContext, statuslessFilterState, surfaceIssues],
);
const filteredGanttIssues = useMemo(
() => applyIssueFilters(ganttIssues, baseFilterState, filterContext),
[baseFilterState, filterContext, ganttIssues],
);
// The assignee-grouped board renders straight from `groups`, bypassing the
// flat applyIssueFilters output — re-apply the client-only display filters
// (Show sub-issues + agents-working) per group.
const filteredAssigneeGroups = useMemo(
() =>
filterAssigneeGroups(assigneeGroupsQuery.data?.groups, {
showSubIssues,
agentRunningFilter,
runningIssueIds: activity.runningIssueIds,
}),
[
activity.runningIssueIds,
agentRunningFilter,
assigneeGroupsQuery.data?.groups,
showSubIssues,
],
);
const { data: childProgressMap = EMPTY_CHILD_PROGRESS } = useQuery(
childIssueProgressOptions(wsId),
);
const { data: projects = EMPTY_PROJECTS } = useQuery({
...projectListOptions(wsId),
enabled: loadProjects,
});
const projectMap = useMemo(
() => new Map(projects.map((project) => [project.id, project])),
[projects],
);
const visibleStatuses = useMemo<IssueStatus[]>(() => {
// Default view shows every lifecycle status, `cancelled` last (its
// canonical position in ALL_STATUSES). An active status filter narrows to
// the selected subset while preserving that order.
if (statusFilters.length > 0) {
return ALL_STATUSES.filter((s) => statusFilters.includes(s));
}
return ALL_STATUSES;
}, [statusFilters]);
// Hidden columns are the lifecycle statuses not currently visible, so
// `cancelled` participates in the board show/hide controls exactly like the
// rest of the statuses.
const hiddenStatuses = useMemo<IssueStatus[]>(
() => ALL_STATUSES.filter((s) => !visibleStatuses.includes(s)),
[visibleStatuses],
);
const activeFilters = useMemo(
() => ({
priorityFilters,
assigneeFilters,
includeNoAssignee,
creatorFilters,
projectFilters,
includeNoProject,
labelFilters,
agentRunningFilter,
showSubIssues,
}),
[
agentRunningFilter,
assigneeFilters,
creatorFilters,
includeNoAssignee,
includeNoProject,
labelFilters,
priorityFilters,
projectFilters,
showSubIssues,
],
);
const isLoading = usesAssigneeBoard
? assigneeGroupsQuery.isLoading
: usesGantt
? ganttIssuesQuery.isLoading
: statusIssuesQuery.isLoading;
// Placeholder-backed revalidation of the ACTIVE query only. First loads are
// isLoading (no previous data to place-hold); gantt has no placeholder
// phase (its key carries no sort/filter).
const isRefreshing = usesAssigneeBoard
? assigneeGroupsQuery.isPlaceholderData
: usesGantt
? false
: statusIssuesQuery.isPlaceholderData;
return {
surfaceIssues,
projectIssues: surfaceIssues,
issues,
swimlaneIssues,
filteredGanttIssues,
assigneeGroups: usesAssigneeBoard ? filteredAssigneeGroups : undefined,
assigneeGroupQueryKey: usesAssigneeBoard
? activeAssigneeGroupsOptions.queryKey
: undefined,
assigneeGroupFilter: usesAssigneeBoard ? assigneeGroupFilter : undefined,
filter: queryPlan.queryFilter,
loadMoreScope: queryPlan.loadMoreScope,
loadMoreFilter: queryPlan.loadMoreFilter,
ganttIssues,
visibleStatuses,
hiddenStatuses,
activeFilters,
activity,
childProgressMap,
projectMap,
isLoading,
isRefreshing,
// isEmpty asserts "this window has no issues". The board/list/swimlane
// data IS the full window, so an empty result proves it. The gantt query
// is a scheduled-only PROJECTION — an empty subset cannot prove the
// window is empty, so never claim it (same "uncertain → don't assert"
// rule as surface membership). GanttView renders its own accurate
// "no scheduled issues" empty state instead of the generic create-issue
// one.
isEmpty: !isLoading && !usesGantt && surfaceIssues.length === 0,
};
}