mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 04:56:20 +02:00
Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
693 lines
25 KiB
TypeScript
693 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|
import type { QueryKey } from "@tanstack/react-query";
|
|
import { api } from "@multica/core/api";
|
|
import type {
|
|
Issue,
|
|
IssueAssigneeGroup,
|
|
IssueStatus,
|
|
IssueTableFacetSpec,
|
|
IssueTableFacetsResponse,
|
|
IssueTableGroupsRequest,
|
|
IssueTableQuerySpec,
|
|
Project,
|
|
} from "@multica/core/types";
|
|
import { workspaceWorkingAgentsOptions } from "@multica/core/agents";
|
|
import { useWorkspaceId } from "@multica/core/hooks";
|
|
import { ALL_STATUSES } from "@multica/core/issues/config";
|
|
import { dateOnlyToLocalDate } from "@multica/core/issues/date";
|
|
import type {
|
|
AssigneeGroupedIssuesFilter,
|
|
IssueSortParam,
|
|
MyIssuesFilter,
|
|
} from "@multica/core/issues/queries";
|
|
import { issueTableFacetsOptions } from "@multica/core/issues/queries";
|
|
import {
|
|
buildIssueSurfaceQueryPlan,
|
|
type IssueSurfaceQueryPlan,
|
|
} from "@multica/core/issues/surface/query-plan";
|
|
import type { IssueScope } from "@multica/core/issues/surface/scope";
|
|
import type { IssueDateFilter, SortField } from "@multica/core/issues/stores/view-store";
|
|
import { propertyListOptions } from "@multica/core/properties";
|
|
import { propertyIdFromViewKey } from "@multica/core/issues/stores/view-store";
|
|
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
|
|
import type { IssueFilters } from "../utils/filter";
|
|
import type { ChildProgress } from "../components/list-row";
|
|
import { IssueTableExportIntegrityError } from "../components/table-view-model";
|
|
import type { IssueSurfaceMode } from "./types";
|
|
import type { IssueSurfaceActions } from "./actions-context";
|
|
import {
|
|
type IssueSurfaceSelection,
|
|
useCreateIssueSurfaceSelection,
|
|
} from "./selection-context";
|
|
import type { IssueCreateDefaults } from "./types";
|
|
import {
|
|
useIssueSurfaceActions,
|
|
type MoveIssueUpdates,
|
|
} from "./use-issue-surface-actions";
|
|
import { useIssueSurfaceData } from "./use-issue-surface-data";
|
|
import {
|
|
useIssueStatusBranches,
|
|
type IssueStatusPagination,
|
|
} from "./use-issue-status-branches";
|
|
import {
|
|
useIssueGroupBranches,
|
|
type IssueGroupBranches,
|
|
} from "./use-issue-group-branches";
|
|
|
|
interface UseIssueSurfaceControllerInput {
|
|
scope: IssueScope;
|
|
modes: IssueSurfaceMode[];
|
|
createDefaults?: IssueCreateDefaults;
|
|
search?: string;
|
|
}
|
|
|
|
export interface IssueSurfaceController {
|
|
scopeKey: string;
|
|
projectId?: string;
|
|
createDefaults: IssueCreateDefaults;
|
|
viewMode: IssueSurfaceMode;
|
|
allowGantt: boolean;
|
|
surfaceIssues: Issue[];
|
|
projectIssues: Issue[];
|
|
issues: Issue[];
|
|
swimlaneIssues: Issue[];
|
|
/** The rows the agents-working filter would leave on screen. Feeds the
|
|
* header chip so its count IS the post-click row count (MUL-4884). */
|
|
/** See IssueSurfaceData.workingScopeIssues — undefined means UNKNOWN. */
|
|
workingScopeIssues: Issue[] | undefined;
|
|
filteredGanttIssues: Issue[];
|
|
assigneeGroups?: IssueAssigneeGroup[];
|
|
assigneeGroupQueryKey?: QueryKey;
|
|
assigneeGroupFilter?: AssigneeGroupedIssuesFilter;
|
|
filter: MyIssuesFilter;
|
|
loadMoreScope?: string;
|
|
loadMoreFilter?: MyIssuesFilter;
|
|
sort: IssueSortParam;
|
|
ganttIssues: Issue[];
|
|
visibleStatuses: IssueStatus[];
|
|
hiddenStatuses: IssueStatus[];
|
|
/** Exact server counts plus cursor controls for List/status Board. */
|
|
statusPagination?: IssueStatusPagination;
|
|
/** Exact group catalog plus independent row cursors for Assignee/Property
|
|
* Board and compound Swimlane cells. */
|
|
groupBranches?: IssueGroupBranches;
|
|
activeFilters: Omit<IssueFilters, "statusFilters">;
|
|
actions: IssueSurfaceActions;
|
|
selection: IssueSurfaceSelection;
|
|
childProgressMap: Map<string, ChildProgress>;
|
|
projectMap: Map<string, Project>;
|
|
resolveTableExportLookups: (needs: {
|
|
projects: boolean;
|
|
childProgress: boolean;
|
|
}) => Promise<{
|
|
projectMap: Map<string, Project>;
|
|
childProgressMap: Map<string, ChildProgress>;
|
|
}>;
|
|
tableSearch: string;
|
|
/** Canonical server-owned Table membership. */
|
|
tableQuerySpec: IssueTableQuerySpec;
|
|
/** Exact disjunctive counts for the active server-backed filter submenu. */
|
|
tableFacetCounts?: IssueTableFacetsResponse;
|
|
/** Whether scopedIssues is a complete client window for local count use. */
|
|
facetCountsExact: boolean;
|
|
/** Load one server facet when its filter submenu is opened. */
|
|
setActiveTableFacet: (facet: IssueTableFacetSpec | null) => void;
|
|
setTableSearch: (query: string) => void;
|
|
exportTableIssues: () => Promise<Issue[]>;
|
|
isLoading: boolean;
|
|
/** See IssueSurfaceData.isRefreshing — placeholder-backed revalidation. */
|
|
isRefreshing: boolean;
|
|
isEmpty: boolean;
|
|
openCreateIssue: (defaults?: IssueCreateDefaults) => void;
|
|
moveIssue: (
|
|
issueId: string,
|
|
updates: MoveIssueUpdates,
|
|
onSettled?: () => void,
|
|
) => void;
|
|
}
|
|
|
|
function issueDateFilterToApiParams(filter: IssueDateFilter | null) {
|
|
if (!filter) return {};
|
|
|
|
const from = dateOnlyToLocalDate(filter.from);
|
|
const to = dateOnlyToLocalDate(filter.to);
|
|
if (!from || !to) return {};
|
|
|
|
const start = from <= to ? from : to;
|
|
const endSource = from <= to ? to : from;
|
|
const end = new Date(endSource);
|
|
end.setDate(end.getDate() + 1);
|
|
|
|
return {
|
|
date_field: filter.field,
|
|
date_start: start.toISOString(),
|
|
date_end: end.toISOString(),
|
|
};
|
|
}
|
|
|
|
function useDebouncedTableSearch(value: string, delayMs = 250) {
|
|
const [debouncedValue, setDebouncedValue] = useState(value.trim());
|
|
|
|
useEffect(() => {
|
|
const timer = window.setTimeout(
|
|
() => setDebouncedValue(value.trim()),
|
|
delayMs,
|
|
);
|
|
return () => window.clearTimeout(timer);
|
|
}, [delayMs, value]);
|
|
|
|
return debouncedValue;
|
|
}
|
|
|
|
export function useIssueSurfaceController({
|
|
scope,
|
|
modes,
|
|
createDefaults,
|
|
search = "",
|
|
}: UseIssueSurfaceControllerInput): IssueSurfaceController {
|
|
const wsId = useWorkspaceId();
|
|
const queryPlan = useMemo<IssueSurfaceQueryPlan>(
|
|
() => buildIssueSurfaceQueryPlan(scope),
|
|
[scope],
|
|
);
|
|
const scopeKey = queryPlan.scopeKey;
|
|
const projectId = scope.type === "project" ? scope.projectId : undefined;
|
|
|
|
const viewMode = useViewStore((s) => s.viewMode);
|
|
const setViewMode = useViewStore((s) => s.setViewMode);
|
|
const grouping = useViewStore((s) => s.grouping);
|
|
const sortBy = useViewStore((s) => s.sortBy);
|
|
const sortDirection = useViewStore((s) => s.sortDirection);
|
|
const dateFilter = useViewStore((s) => s.dateFilter);
|
|
const statusFilters = useViewStore((s) => s.statusFilters);
|
|
const priorityFilters = useViewStore((s) => s.priorityFilters);
|
|
const assigneeFilters = useViewStore((s) => s.assigneeFilters);
|
|
const includeNoAssignee = useViewStore((s) => s.includeNoAssignee);
|
|
const creatorFilters = useViewStore((s) => s.creatorFilters);
|
|
const projectFilters = useViewStore((s) => s.projectFilters);
|
|
const includeNoProject = useViewStore((s) => s.includeNoProject);
|
|
const labelFilters = useViewStore((s) => s.labelFilters);
|
|
const propertyFilters = useViewStore((s) => s.propertyFilters);
|
|
const agentRunningFilter = useViewStore((s) => s.agentRunningFilter);
|
|
const showSubIssues = useViewStore((s) => s.showSubIssues);
|
|
const ganttShowCompleted = useViewStore((s) => s.ganttShowCompleted);
|
|
const cardProperties = useViewStore((s) => s.cardProperties);
|
|
const swimlaneGrouping = useViewStore((s) => s.swimlaneGrouping);
|
|
const tableColumns = useViewStore((s) => s.tableColumns);
|
|
const listCollapsedStatuses = useViewStore((s) => s.listCollapsedStatuses);
|
|
const [tableSearch, setTableSearch] = useState("");
|
|
|
|
const allowedModes = useMemo(() => new Set<IssueSurfaceMode>(modes), [modes]);
|
|
const fallbackMode = modes[0] ?? "list";
|
|
const effectiveViewMode = allowedModes.has(viewMode as IssueSurfaceMode)
|
|
? (viewMode as IssueSurfaceMode)
|
|
: fallbackMode;
|
|
|
|
useEffect(() => {
|
|
if (!allowedModes.has(viewMode as IssueSurfaceMode)) {
|
|
setViewMode(fallbackMode);
|
|
}
|
|
}, [allowedModes, fallbackMode, setViewMode, viewMode]);
|
|
|
|
const resolvedCreateDefaults = useMemo(
|
|
() => ({ ...queryPlan.createDefaults, ...createDefaults }),
|
|
[createDefaults, queryPlan.createDefaults],
|
|
);
|
|
|
|
const dateParams = useMemo(
|
|
() => issueDateFilterToApiParams(dateFilter),
|
|
[dateFilter],
|
|
);
|
|
// Active property catalog. Persisted view state can outlive definitions
|
|
// (archive/delete): filters keyed by a non-active definition are stripped
|
|
// before they reach the predicates, and a sort on a non-active definition
|
|
// degrades to manual order — matching what the header already shows.
|
|
const { data: workspaceProperties = [], isSuccess: catalogSettled } = useQuery(propertyListOptions(wsId));
|
|
const activePropertyIds = useMemo(
|
|
() => new Set(workspaceProperties.map((p) => p.id)),
|
|
[workspaceProperties],
|
|
);
|
|
const effectivePropertyFilters = useMemo(() => {
|
|
// While the catalog is still loading (or errored), persisted filters are
|
|
// passed through UNCHANGED: treating a cold catalog as confirmed-empty
|
|
// would silently drop the user's filters on first paint (clean-room
|
|
// review F6). Old servers 404 into a SETTLED empty catalog, so the
|
|
// stripping below still protects that path.
|
|
if (!catalogSettled) return propertyFilters;
|
|
const entries = Object.entries(propertyFilters).filter(
|
|
([propertyId, selected]) => selected.length > 0 && activePropertyIds.has(propertyId),
|
|
);
|
|
if (entries.length === Object.keys(propertyFilters).length) return propertyFilters;
|
|
return Object.fromEntries(entries);
|
|
}, [activePropertyIds, catalogSettled, propertyFilters]);
|
|
|
|
// Custom-property sorts and filters are served by the backend: the sort
|
|
// param carries `property:<id>` (typed ORDER BY expression server-side)
|
|
// and the window bag carries the property filter, so results are correct
|
|
// across pagination — not just the loaded window. A sort pinned to a
|
|
// non-active definition degrades to position order.
|
|
const rawPropertySortId = propertyIdFromViewKey(sortBy);
|
|
const propertySortId =
|
|
rawPropertySortId && (!catalogSettled || activePropertyIds.has(rawPropertySortId))
|
|
? rawPropertySortId
|
|
: null;
|
|
const sort = useMemo<IssueSortParam>(() => {
|
|
const sortBy_: IssueSortParam["sort_by"] = propertySortId
|
|
? `property:${propertySortId}`
|
|
: rawPropertySortId
|
|
? "position"
|
|
: (sortBy as Exclude<SortField, `property:${string}`>);
|
|
return {
|
|
sort_by: sortBy_,
|
|
sort_direction: sortBy_ !== "position" ? sortDirection : undefined,
|
|
...dateParams,
|
|
...(Object.keys(effectivePropertyFilters).length > 0
|
|
? { properties: effectivePropertyFilters }
|
|
: {}),
|
|
};
|
|
}, [dateParams, effectivePropertyFilters, propertySortId, rawPropertySortId, sortBy, sortDirection]);
|
|
|
|
const groupingPropertyId = propertyIdFromViewKey(grouping);
|
|
const activeGroupingProperty = groupingPropertyId
|
|
? workspaceProperties.find(
|
|
(property) =>
|
|
property.id === groupingPropertyId && property.type === "select",
|
|
) ?? null
|
|
: null;
|
|
const effectiveGrouping =
|
|
groupingPropertyId && catalogSettled && !activeGroupingProperty
|
|
? "status"
|
|
: grouping;
|
|
const usesAssigneeBoard =
|
|
effectiveViewMode === "board" && effectiveGrouping === "assignee";
|
|
const usesGantt = effectiveViewMode === "gantt" && !!projectId;
|
|
const usesTable = effectiveViewMode === "table";
|
|
const activeSearch = usesTable ? tableSearch : search;
|
|
const debouncedActiveSearch = useDebouncedTableSearch(activeSearch);
|
|
const usesServerStatusSurface =
|
|
effectiveViewMode === "list" ||
|
|
(effectiveViewMode === "board" && effectiveGrouping === "status");
|
|
const usesServerGroupSurface =
|
|
(effectiveViewMode === "board" && effectiveGrouping !== "status") ||
|
|
effectiveViewMode === "swimlane";
|
|
const usesServerFacets =
|
|
usesTable || usesServerStatusSurface || usesServerGroupSurface;
|
|
const serverStatuses = useMemo<IssueStatus[]>(
|
|
() => {
|
|
const visible =
|
|
statusFilters.length > 0
|
|
? ALL_STATUSES.filter((status) => statusFilters.includes(status))
|
|
: [...ALL_STATUSES];
|
|
return effectiveViewMode === "list"
|
|
? visible.filter((status) => !listCollapsedStatuses.includes(status))
|
|
: visible;
|
|
},
|
|
[effectiveViewMode, listCollapsedStatuses, statusFilters],
|
|
);
|
|
|
|
const projectFilterState = useMemo(
|
|
() => ({
|
|
projectFilters: scope.type === "project" ? [] : projectFilters,
|
|
includeNoProject: scope.type === "project" ? false : includeNoProject,
|
|
}),
|
|
[includeNoProject, projectFilters, scope.type],
|
|
);
|
|
const { projectFilters: viewProjectFilters, includeNoProject: viewIncludeNoProject } =
|
|
projectFilterState;
|
|
|
|
const workingAgentMineRelation =
|
|
scope.type === "my"
|
|
? scope.relation === "all"
|
|
? "any"
|
|
: scope.relation
|
|
: undefined;
|
|
const { data: workspaceWorkingAgents = [] } = useQuery(
|
|
workspaceWorkingAgentsOptions(wsId, "issue", workingAgentMineRelation),
|
|
);
|
|
const workingIssueIDs = useMemo(() => {
|
|
const issueIDs = new Set<string>();
|
|
for (const agent of workspaceWorkingAgents) {
|
|
for (const issueID of agent.issue_ids) issueIDs.add(issueID);
|
|
}
|
|
return issueIDs;
|
|
}, [workspaceWorkingAgents]);
|
|
|
|
const tableQuerySpec = useMemo<IssueTableQuerySpec>(() => {
|
|
let queryScope: IssueTableQuerySpec["scope"];
|
|
switch (scope.type) {
|
|
case "workspace":
|
|
queryScope = {
|
|
kind: "workspace",
|
|
...(scope.actorKind === "members"
|
|
? { assignee_types: ["member" as const] }
|
|
: scope.actorKind === "agents"
|
|
? { assignee_types: ["agent" as const, "squad" as const] }
|
|
: {}),
|
|
};
|
|
break;
|
|
case "project":
|
|
queryScope = { kind: "project", project_id: scope.projectId };
|
|
break;
|
|
case "my":
|
|
queryScope = {
|
|
kind: "my",
|
|
relation: scope.relation === "all" ? "any" : scope.relation,
|
|
};
|
|
break;
|
|
case "actor":
|
|
queryScope = {
|
|
kind: scope.relation === "assigned" ? "assignee" : "creator",
|
|
actor: { type: scope.actorType, id: scope.actorId },
|
|
};
|
|
break;
|
|
case "team":
|
|
throw new Error("Team issue scope is not supported by the Table query");
|
|
}
|
|
|
|
const date =
|
|
dateParams.date_field && dateParams.date_start && dateParams.date_end
|
|
? {
|
|
field: dateParams.date_field,
|
|
start: dateParams.date_start,
|
|
end: dateParams.date_end,
|
|
}
|
|
: undefined;
|
|
return {
|
|
scope: queryScope,
|
|
filters: {
|
|
...(statusFilters.length > 0 ? { statuses: statusFilters } : {}),
|
|
...(priorityFilters.length > 0 ? { priorities: priorityFilters } : {}),
|
|
...(assigneeFilters.length > 0 ? { assignees: assigneeFilters } : {}),
|
|
...(includeNoAssignee ? { include_no_assignee: true } : {}),
|
|
...(creatorFilters.length > 0 ? { creators: creatorFilters } : {}),
|
|
...(viewProjectFilters.length > 0
|
|
? { project_ids: viewProjectFilters }
|
|
: {}),
|
|
...(viewIncludeNoProject ? { include_no_project: true } : {}),
|
|
...(labelFilters.length > 0 ? { label_ids: labelFilters } : {}),
|
|
...(Object.keys(effectivePropertyFilters).length > 0
|
|
? { properties: effectivePropertyFilters }
|
|
: {}),
|
|
...(date ? { date } : {}),
|
|
...(agentRunningFilter
|
|
? { working_issue_ids: [...workingIssueIDs] }
|
|
: {}),
|
|
include_sub_issues: showSubIssues,
|
|
},
|
|
...(debouncedActiveSearch ? { search: debouncedActiveSearch } : {}),
|
|
sort: {
|
|
field: sort.sort_by ?? "position",
|
|
direction: sort.sort_direction ?? "asc",
|
|
},
|
|
};
|
|
}, [
|
|
agentRunningFilter,
|
|
assigneeFilters,
|
|
creatorFilters,
|
|
dateParams,
|
|
debouncedActiveSearch,
|
|
effectivePropertyFilters,
|
|
includeNoAssignee,
|
|
labelFilters,
|
|
priorityFilters,
|
|
scope,
|
|
showSubIssues,
|
|
sort.sort_by,
|
|
sort.sort_direction,
|
|
statusFilters,
|
|
viewIncludeNoProject,
|
|
viewProjectFilters,
|
|
workingIssueIDs,
|
|
]);
|
|
|
|
const [activeTableFacet, setActiveTableFacet] =
|
|
useState<IssueTableFacetSpec | null>(null);
|
|
const requestedFacets = useMemo<IssueTableFacetSpec[]>(() => {
|
|
const facets: IssueTableFacetSpec[] = [];
|
|
if (usesServerStatusSurface) facets.push({ kind: "status" });
|
|
if (
|
|
activeTableFacet &&
|
|
!facets.some(
|
|
(facet) =>
|
|
facet.kind === activeTableFacet.kind &&
|
|
(facet.kind !== "property" ||
|
|
activeTableFacet.kind !== "property" ||
|
|
facet.property_id === activeTableFacet.property_id),
|
|
)
|
|
) {
|
|
facets.push(activeTableFacet);
|
|
}
|
|
// The request shape remains total while disabled.
|
|
return facets.length > 0 ? facets : [{ kind: "status" }];
|
|
}, [activeTableFacet, usesServerStatusSurface]);
|
|
const tableFacetRequest = useMemo(
|
|
() => ({
|
|
query: tableQuerySpec,
|
|
facets: requestedFacets,
|
|
// Status surfaces consume the facet total as their authoritative empty
|
|
// state. Table rows/groups already own the displayed total.
|
|
include_total: usesServerStatusSurface,
|
|
}),
|
|
[requestedFacets, tableQuerySpec, usesServerStatusSurface],
|
|
);
|
|
const tableFacetsQuery = useQuery({
|
|
...issueTableFacetsOptions(wsId, tableFacetRequest),
|
|
placeholderData: keepPreviousData,
|
|
// Counts are only visible inside one open filter submenu. Eagerly loading
|
|
// every custom-property facet made a Table mount issue up to 47 SQL
|
|
// statements and repeatedly scan the issue table after invalidation.
|
|
enabled:
|
|
usesServerStatusSurface ||
|
|
((usesTable || usesServerGroupSurface) && activeTableFacet !== null),
|
|
});
|
|
useEffect(() => {
|
|
if (!usesServerFacets) setActiveTableFacet(null);
|
|
}, [usesServerFacets]);
|
|
const requestActiveTableFacet = useCallback(
|
|
(facet: IssueTableFacetSpec | null) => {
|
|
setActiveTableFacet(usesServerFacets ? facet : null);
|
|
},
|
|
[usesServerFacets],
|
|
);
|
|
const serverStatusBranches = useIssueStatusBranches({
|
|
wsId,
|
|
query: tableQuerySpec,
|
|
statuses: serverStatuses,
|
|
facets: tableFacetsQuery.data,
|
|
facetsPending: tableFacetsQuery.isPending,
|
|
facetsFetching: tableFacetsQuery.isFetching,
|
|
enabled: usesServerStatusSurface,
|
|
});
|
|
const serverGroupSpec = useMemo<IssueTableGroupsRequest["group"]>(() => {
|
|
if (effectiveViewMode === "swimlane") {
|
|
return {
|
|
kind: "compound",
|
|
primary: swimlaneGrouping,
|
|
secondary: "status",
|
|
secondary_values: serverStatuses,
|
|
};
|
|
}
|
|
const propertyId = propertyIdFromViewKey(effectiveGrouping);
|
|
if (propertyId) {
|
|
return {
|
|
kind: "property",
|
|
property_id: propertyId,
|
|
include_empty: true,
|
|
};
|
|
}
|
|
return { kind: "assignee" };
|
|
}, [
|
|
effectiveGrouping,
|
|
effectiveViewMode,
|
|
serverStatuses,
|
|
swimlaneGrouping,
|
|
]);
|
|
const serverGroupQuery = useMemo<IssueTableQuerySpec>(() => {
|
|
if (effectiveViewMode !== "swimlane") return tableQuerySpec;
|
|
const { statuses: _statuses, ...filters } = tableQuerySpec.filters;
|
|
return { ...tableQuerySpec, filters };
|
|
}, [effectiveViewMode, tableQuerySpec]);
|
|
const serverGroupBranches = useIssueGroupBranches({
|
|
wsId,
|
|
query: serverGroupQuery,
|
|
group: serverGroupSpec,
|
|
secondaryValues:
|
|
effectiveViewMode === "swimlane" ? serverStatuses : undefined,
|
|
observeEmptyBranches:
|
|
effectiveViewMode === "swimlane" ||
|
|
(effectiveViewMode === "board" && activeGroupingProperty !== null),
|
|
enabled: usesServerGroupSurface,
|
|
});
|
|
|
|
// Selection is only meaningful within the current membership window: batch
|
|
// actions act on selected ids while export/common-field consumers intersect
|
|
// with visible rows, so a selection that survives a membership change lets
|
|
// "1 selected" mean different sets to different consumers (round-2 review
|
|
// P1#2). Reset whenever any membership-affecting input changes. Sort is
|
|
// excluded on purpose — reordering does not change membership. The live
|
|
// running set is also excluded: while the agents-working filter is on, a
|
|
// task finishing should not wipe the user's selection mid-action.
|
|
const membershipKey = useMemo(
|
|
() =>
|
|
JSON.stringify([
|
|
statusFilters,
|
|
priorityFilters,
|
|
assigneeFilters,
|
|
includeNoAssignee,
|
|
creatorFilters,
|
|
viewProjectFilters,
|
|
viewIncludeNoProject,
|
|
labelFilters,
|
|
effectivePropertyFilters,
|
|
agentRunningFilter,
|
|
showSubIssues,
|
|
dateParams,
|
|
debouncedActiveSearch,
|
|
]),
|
|
[
|
|
agentRunningFilter,
|
|
assigneeFilters,
|
|
creatorFilters,
|
|
dateParams,
|
|
debouncedActiveSearch,
|
|
effectivePropertyFilters,
|
|
includeNoAssignee,
|
|
labelFilters,
|
|
priorityFilters,
|
|
showSubIssues,
|
|
statusFilters,
|
|
viewIncludeNoProject,
|
|
viewProjectFilters,
|
|
],
|
|
);
|
|
const selection = useCreateIssueSurfaceSelection(
|
|
scopeKey,
|
|
`${scopeKey}:${effectiveViewMode}:${membershipKey}`,
|
|
);
|
|
|
|
const data = useIssueSurfaceData({
|
|
wsId,
|
|
queryPlan,
|
|
projectId,
|
|
usesAssigneeBoard,
|
|
usesGantt,
|
|
usesTable,
|
|
serverStatusBranches,
|
|
serverGroupBranches,
|
|
ganttShowCompleted,
|
|
sort,
|
|
statusFilters,
|
|
priorityFilters,
|
|
assigneeFilters,
|
|
includeNoAssignee,
|
|
agentRunningFilter,
|
|
creatorFilters,
|
|
projectFilters: viewProjectFilters,
|
|
includeNoProject: viewIncludeNoProject,
|
|
labelFilters,
|
|
propertyFilters: effectivePropertyFilters,
|
|
workingIssueIDs,
|
|
showSubIssues,
|
|
loadProjects:
|
|
cardProperties.project ||
|
|
(usesTable && tableColumns.some((column) => column.key === "project")) ||
|
|
(effectiveViewMode === "swimlane" && swimlaneGrouping === "project"),
|
|
});
|
|
|
|
const exportTableIssues = useCallback(async () => {
|
|
const issues: Issue[] = [];
|
|
const seenIssueIds = new Set<string>();
|
|
const seenCursors = new Set<string>();
|
|
let fingerprint: string | null = null;
|
|
let expectedTotal: number | null = null;
|
|
let cursor: string | null = null;
|
|
do {
|
|
if (cursor !== null) {
|
|
if (seenCursors.has(cursor)) throw new IssueTableExportIntegrityError();
|
|
seenCursors.add(cursor);
|
|
}
|
|
const page = await api.listIssueTableRows({
|
|
query: tableQuerySpec,
|
|
group: { kind: "none" },
|
|
group_key: null,
|
|
hierarchy: { enabled: false },
|
|
parent_id: null,
|
|
page: { limit: 100, cursor },
|
|
});
|
|
// parseWithFallback deliberately protects interactive views from schema
|
|
// drift with an empty response. Export must fail closed instead: an empty
|
|
// fingerprint is the fallback sentinel and must never create a truncated
|
|
// CSV that looks successful.
|
|
if (!page.query_fingerprint) throw new IssueTableExportIntegrityError();
|
|
fingerprint ??= page.query_fingerprint;
|
|
if (cursor === null) expectedTotal = page.total;
|
|
if (
|
|
page.query_fingerprint !== fingerprint ||
|
|
page.group_key !== null ||
|
|
page.parent_id !== null
|
|
) {
|
|
throw new IssueTableExportIntegrityError();
|
|
}
|
|
for (const row of page.rows) {
|
|
if (seenIssueIds.has(row.issue.id)) {
|
|
throw new IssueTableExportIntegrityError();
|
|
}
|
|
seenIssueIds.add(row.issue.id);
|
|
issues.push(row.issue);
|
|
}
|
|
cursor = page.next_cursor;
|
|
} while (cursor);
|
|
if (issues.length !== (expectedTotal ?? 0)) {
|
|
throw new IssueTableExportIntegrityError();
|
|
}
|
|
return issues;
|
|
}, [tableQuerySpec]);
|
|
|
|
const { actions, openCreateIssue, moveIssue } = useIssueSurfaceActions({
|
|
createDefaults: resolvedCreateDefaults,
|
|
});
|
|
|
|
return {
|
|
scopeKey,
|
|
projectId,
|
|
createDefaults: resolvedCreateDefaults,
|
|
viewMode: effectiveViewMode,
|
|
allowGantt: allowedModes.has("gantt") && !!projectId,
|
|
...data,
|
|
statusPagination: usesServerStatusSurface
|
|
? data.statusPagination
|
|
: undefined,
|
|
groupBranches: usesServerGroupSurface
|
|
? serverGroupBranches
|
|
: undefined,
|
|
// Keep TableView mounted for an empty search result so its local search
|
|
// control remains available to refine or clear the query. Include the
|
|
// debounced value as well to avoid a brief empty-screen flash while a
|
|
// cleared query is waiting to re-fetch the unsearched window.
|
|
isEmpty:
|
|
data.isEmpty &&
|
|
!data.isRefreshing &&
|
|
!(usesTable && (tableSearch.trim() || debouncedActiveSearch)),
|
|
sort,
|
|
actions,
|
|
selection,
|
|
tableSearch,
|
|
tableQuerySpec,
|
|
tableFacetCounts:
|
|
usesServerStatusSurface ||
|
|
((usesTable || usesServerGroupSurface) && activeTableFacet !== null)
|
|
? tableFacetsQuery.data
|
|
: undefined,
|
|
facetCountsExact:
|
|
!usesTable && !usesServerStatusSurface && !usesServerGroupSurface,
|
|
setActiveTableFacet: requestActiveTableFacet,
|
|
setTableSearch,
|
|
openCreateIssue,
|
|
moveIssue,
|
|
exportTableIssues,
|
|
};
|
|
}
|