Files
multica/packages/views/issues/surface/use-issue-status-branches.ts
Multica Eve 40f9ecdd56 MUL-5202: unify Issue Query across List, Board, and Swimlane (#5820)
* MUL-5202: migrate status issue surfaces to table query

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: unify grouped issue surfaces

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: cover move safety boundaries

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: preserve server swimlane semantics

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: keep grouped surface facets exact

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 15:58:25 +08:00

419 lines
12 KiB
TypeScript

"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
useQueries,
useQueryClient,
type UseQueryResult,
} from "@tanstack/react-query";
import { ALL_STATUSES } from "@multica/core/issues/config";
import {
issueKeys,
issueTableRowPageOptions,
} from "@multica/core/issues/queries";
import type {
Issue,
IssueStatus,
IssueTableFacetsResponse,
IssueTableQuerySpec,
IssueTableRowsResponse,
} from "@multica/core/types";
export interface IssueStatusPageState {
total: number;
loaded: number;
hasMore: boolean;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
loadMore: () => void;
retry: () => void;
}
export type IssueStatusPagination = Record<
IssueStatus,
IssueStatusPageState
>;
interface StatusCursorState {
identity: string;
cursors: Record<IssueStatus, Array<string | null>>;
}
interface StatusPageTarget {
status: IssueStatus;
cursor: string | null;
}
interface StatusBranchData {
rows: Issue[];
nextCursor: string | null;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
headUpdatedAt: number;
headFetching: boolean;
}
function statusGroupKey(status: IssueStatus) {
return `status:${status}`;
}
function initialCursorState(
identity: string,
statuses: readonly IssueStatus[],
): StatusCursorState {
const cursors = Object.fromEntries(
ALL_STATUSES.map((status) => [
status,
statuses.includes(status) ? [null] : [],
]),
) as StatusCursorState["cursors"];
return { identity, cursors };
}
function rebaseCursorState(
state: StatusCursorState,
identity: string,
statuses: readonly IssueStatus[],
) {
const current =
state.identity === identity ? state : initialCursorState(identity, statuses);
let cursors: StatusCursorState["cursors"] | null = null;
for (const status of statuses) {
if (current.cursors[status].length > 0) continue;
cursors ??= { ...current.cursors };
cursors[status] = [null];
}
return cursors ? { ...current, cursors } : current;
}
function statusCountsFromFacets(
facets: IssueTableFacetsResponse | undefined,
) {
const counts = new Map<IssueStatus, number>();
const statusFacet = facets?.facets.find((facet) => facet.kind === "status");
for (const value of statusFacet?.values ?? []) {
if (ALL_STATUSES.includes(value.key as IssueStatus)) {
counts.set(value.key as IssueStatus, value.count);
}
}
return counts;
}
export interface IssueStatusBranches {
enabled: boolean;
issues: Issue[];
pagination: IssueStatusPagination;
total: number;
isTotalKnown: boolean;
isLoading: boolean;
isRefreshing: boolean;
}
/**
* Server-authoritative status branches shared by List and status-grouped
* Board. Every branch page is the same keyset-paged `/table/rows` contract
* used by Table; status totals come from the disjunctive status facet, so
* hidden/empty columns never depend on the loaded card window.
*/
export function useIssueStatusBranches({
wsId,
query,
statuses,
facets,
facetsPending,
facetsFetching,
enabled,
}: {
wsId: string;
query: IssueTableQuerySpec;
statuses: readonly IssueStatus[];
facets: IssueTableFacetsResponse | undefined;
facetsPending: boolean;
facetsFetching: boolean;
enabled: boolean;
}): IssueStatusBranches {
const queryClient = useQueryClient();
const identity = useMemo(() => JSON.stringify(query), [query]);
const [cursorState, setCursorState] = useState<StatusCursorState>(() =>
initialCursorState(identity, statuses),
);
const activeCursorState = rebaseCursorState(
cursorState,
identity,
statuses,
);
useEffect(() => {
if (cursorState !== activeCursorState) {
setCursorState(activeCursorState);
}
}, [activeCursorState, cursorState]);
const pageTargets = useMemo<StatusPageTarget[]>(
() =>
enabled
? statuses.flatMap((status) =>
activeCursorState.cursors[status].map((cursor) => ({
status,
cursor,
})),
)
: [],
[activeCursorState.cursors, enabled, statuses],
);
const headPlaceholderRef = useRef(
new Map<IssueStatus, IssueTableRowsResponse>(),
);
const pageQueries = useMemo(
() =>
pageTargets.map(({ status, cursor }) => {
const placeholder =
cursor === null ? headPlaceholderRef.current.get(status) : undefined;
return {
...issueTableRowPageOptions(wsId, {
query,
group: { kind: "status" },
group_key: statusGroupKey(status),
hierarchy: { enabled: false },
parent_id: null,
page: { limit: 50, cursor },
}),
// useQueries replaces observers when the query hash changes, so its
// built-in keepPreviousData cannot bridge a filter/sort transition.
// Retain only the last settled HEAD per fixed status branch. Tails
// are deliberately detached; exact facets remain server-owned.
...(placeholder ? { placeholderData: () => placeholder } : {}),
enabled,
};
}),
[enabled, pageTargets, query, wsId],
);
const pageResults = useQueries({ queries: pageQueries }) as Array<
UseQueryResult<IssueTableRowsResponse, Error>
>;
useEffect(() => {
const next = new Map(headPlaceholderRef.current);
for (let index = 0; index < pageTargets.length; index += 1) {
const target = pageTargets[index];
const result = pageResults[index];
if (
target?.cursor !== null ||
!result?.data ||
result.isPlaceholderData ||
result.isError
) {
continue;
}
next.set(target.status, result.data);
}
headPlaceholderRef.current = next;
}, [pageResults, pageTargets]);
const branchData = useMemo(() => {
const result = new Map<IssueStatus, StatusBranchData>();
for (const status of statuses) {
result.set(status, {
rows: [],
nextCursor: null,
isLoading: false,
isFetching: false,
isError: false,
headUpdatedAt: 0,
headFetching: false,
});
}
const headFetching = new Set<IssueStatus>();
for (let index = 0; index < pageTargets.length; index += 1) {
const target = pageTargets[index];
const queryResult = pageResults[index];
if (
target?.cursor === null &&
queryResult?.isFetching &&
activeCursorState.cursors[target.status].length > 1
) {
headFetching.add(target.status);
}
}
const seenByStatus = new Map<IssueStatus, Set<string>>();
for (let index = 0; index < pageTargets.length; index += 1) {
const target = pageTargets[index];
const queryResult = pageResults[index];
if (!target || !queryResult) continue;
// A broad invalidation makes every cursor stale as soon as the head
// starts refreshing. Hide detached tails immediately; the effect below
// trims their cursor observers before their responses can re-enter.
if (target.cursor !== null && headFetching.has(target.status)) continue;
const current = result.get(target.status);
if (!current) continue;
const page = queryResult.data;
if (page) {
const seen = seenByStatus.get(target.status) ?? new Set<string>();
for (const row of page.rows) {
// Realtime can patch an issue's status before the broad query
// invalidation has moved it between branch caches. Never render a
// patched card under a status it no longer belongs to.
if (row.issue.status !== target.status) continue;
if (seen.has(row.issue.id)) continue;
seen.add(row.issue.id);
current.rows.push(row.issue);
}
seenByStatus.set(target.status, seen);
current.nextCursor = page.next_cursor;
}
current.isLoading ||= queryResult.isPending;
current.isFetching ||= queryResult.isFetching;
current.isError ||= queryResult.isError;
if (target.cursor === null) {
current.headUpdatedAt = queryResult.dataUpdatedAt;
current.headFetching = queryResult.isFetching;
}
}
return result;
}, [
activeCursorState.cursors,
pageResults,
pageTargets,
statuses,
]);
// Once a head page refreshes, its old cursor chain no longer belongs to the
// current snapshot. Match Table's branch behavior by dropping every tail.
const headRevisionRef = useRef<{
identity: string;
revisions: Partial<Record<IssueStatus, number>>;
}>({ identity, revisions: {} });
useEffect(() => {
const previous =
headRevisionRef.current.identity === identity
? headRevisionRef.current.revisions
: {};
const next: Partial<Record<IssueStatus, number>> = {};
const trim = new Set<IssueStatus>();
for (const status of statuses) {
const branch = branchData.get(status);
if (!branch || branch.headUpdatedAt === 0) continue;
next[status] = branch.headUpdatedAt;
const seen = previous[status];
if (
activeCursorState.cursors[status].length > 1 &&
(branch.headFetching ||
(seen !== undefined && seen !== branch.headUpdatedAt))
) {
trim.add(status);
}
}
headRevisionRef.current = { identity, revisions: next };
if (trim.size === 0) return;
setCursorState((previousState) => {
if (previousState.identity !== identity) return previousState;
const cursors = { ...previousState.cursors };
for (const status of trim) cursors[status] = [null];
return { ...previousState, cursors };
});
}, [
activeCursorState.cursors,
branchData,
identity,
statuses,
]);
const counts = useMemo(() => statusCountsFromFacets(facets), [facets]);
const loadMore = useCallback(
(status: IssueStatus) => {
const cursor = branchData.get(status)?.nextCursor;
if (!cursor) return;
setCursorState((previous) => {
if (previous.identity !== identity) return previous;
const current = previous.cursors[status];
if (current.includes(cursor)) return previous;
return {
...previous,
cursors: {
...previous.cursors,
[status]: [...current, cursor],
},
};
});
},
[branchData, identity],
);
const retry = useCallback(
(status: IssueStatus) => {
void queryClient.refetchQueries({
queryKey: issueKeys.tableRows(
wsId,
query,
{ kind: "status" },
statusGroupKey(status),
false,
null,
),
exact: false,
type: "active",
});
},
[query, queryClient, wsId],
);
const pagination = useMemo<IssueStatusPagination>(() => {
return Object.fromEntries(
ALL_STATUSES.map((status) => {
const branch = branchData.get(status);
const loaded = branch?.rows.length ?? 0;
const total = counts.get(status) ?? loaded;
return [
status,
{
total,
loaded,
hasMore: enabled && !!branch?.nextCursor,
isLoading: enabled && (branch?.isLoading ?? false),
isFetching: enabled && (branch?.isFetching ?? false),
isError: enabled && (branch?.isError ?? false),
loadMore: () => loadMore(status),
retry: () => retry(status),
},
];
}),
) as IssueStatusPagination;
}, [branchData, counts, enabled, loadMore, retry]);
const issues = useMemo(
() => statuses.flatMap((status) => branchData.get(status)?.rows ?? []),
[branchData, statuses],
);
const isTotalKnown = facets !== undefined;
const total = facets?.total ?? issues.length;
const rowsPending = statuses.some(
(status) => branchData.get(status)?.isLoading,
);
const rowsFetching = statuses.some(
(status) => branchData.get(status)?.isFetching,
);
return {
enabled,
issues,
pagination,
total,
isTotalKnown,
isLoading: enabled && (facetsPending || rowsPending),
isRefreshing:
enabled &&
!facetsPending &&
!rowsPending &&
(facetsFetching || rowsFetching),
};
}