Files
multica/packages/views/issues/surface/use-issue-surface-data.ts
Lambda 784d9dd08a feat(web): custom-property list surfaces — filter, cards, sort, board grouping (MUL-4463 M2)
Brings custom properties to the issue list surfaces on top of the M1
definitions/values core:

- Filter: per-definition sections in the Filter dropdown (select /
  multi_select options with color dots and counts; checkbox as Yes/No
  pseudo-options). OR within a definition, AND across definitions;
  client-side in applyIssueFilters, mirrored into filterAssigneeGroups
  for the assignee-grouped board. Included in active-filter count and
  Clear all.
- Cards: per-property Display toggles (cardPropertyIds) render value
  chips on board cards and list rows via CustomPropertyValueDisplay.
- Sort: SortField gains property:<id> for number/date definitions.
  Server keeps position order (fixed sort enum); the surface controller
  re-sorts client-side, swimlane/gantt reuse the same comparator.
  Date-only strings compare lexically; missing values sort last.
- Board grouping: IssueGrouping gains property:<id> for select
  definitions — one column per option (definition order) plus a
  trailing No-value column, option-colored headings. Drag-drop moves
  position via UpdateIssue and applies the value through
  useSetIssueProperty/useUnsetIssueProperty (properties are not part
  of UpdateIssueRequest). Stale persisted property groupings fall back
  to status columns.

View-store: propertyFilters + cardPropertyIds persisted via the
partialize allowlist; clearFilters resets property filters; new fields
deep-merge cleanly into pre-existing persisted snapshots.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 17:30:14 +08:00

346 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,
propertyFilters,
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[];
propertyFilters: Record<string, 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,
propertyFilters,
workingOnly: agentRunningFilter,
showSubIssues,
}),
[
agentRunningFilter,
assigneeFilters,
creatorFilters,
includeNoAssignee,
includeNoProject,
labelFilters,
priorityFilters,
projectFilters,
propertyFilters,
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,
propertyFilters,
}),
[
activity.runningIssueIds,
agentRunningFilter,
assigneeGroupsQuery.data?.groups,
propertyFilters,
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,
propertyFilters,
agentRunningFilter,
showSubIssues,
}),
[
agentRunningFilter,
assigneeFilters,
creatorFilters,
includeNoAssignee,
includeNoProject,
labelFilters,
propertyFilters,
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,
};
}