From 570fdce3886f83d468c4dcb628d2280b6a861600 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 25 Apr 2026 22:01:16 -0500 Subject: [PATCH] Scope blocked-by summaries to sub-issue lists Co-Authored-By: Paperclip --- server/src/__tests__/issues-service.test.ts | 5 ++++- server/src/routes/issues.ts | 1 + server/src/services/issues.ts | 13 +++++++++---- ui/src/api/issues.test.ts | 4 ++-- ui/src/api/issues.ts | 2 ++ ui/src/components/IssuesList.tsx | 5 ++--- ui/src/pages/IssueDetail.tsx | 4 ++-- 7 files changed, 22 insertions(+), 12 deletions(-) diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 6a116db2ffb..16afd95a9d7 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -939,7 +939,10 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { type: "blocks", }); - const result = await svc.list(companyId); + const defaultResult = await svc.list(companyId); + expect(defaultResult.find((issue) => issue.id === blockedId)?.blockedBy).toBeUndefined(); + + const result = await svc.list(companyId, { includeBlockedBy: true }); const byId = new Map(result.map((issue) => [issue.id, issue])); expect(byId.get(blockedId)?.blockedBy).toEqual([ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 4458f382ca8..8959d889a63 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -949,6 +949,7 @@ export function issueRoutes( req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1", excludeRoutineExecutions: req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1", + includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1", q: req.query.q as string | undefined, limit, }); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index d70977e579b..48893d036c8 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -101,6 +101,7 @@ export interface IssueFilters { originId?: string; includeRoutineExecutions?: boolean; excludeRoutineExecutions?: boolean; + includeBlockedBy?: boolean; q?: string; limit?: number; } @@ -1303,10 +1304,11 @@ async function blockedByMapForIssues( ): Promise> { const map = new Map(); const uniqueIssueIds = [...new Set(issueIds)]; + if (uniqueIssueIds.length === 0) return map; + for (const issueId of uniqueIssueIds) { map.set(issueId, []); } - if (uniqueIssueIds.length === 0) return map; for (const issueIdChunk of chunkList(uniqueIssueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { const rows = await dbOrTx @@ -1840,6 +1842,7 @@ export function issueService(db: Db) { const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined; const unreadForUserId = filters?.unreadForUserId?.trim() || undefined; const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId; + const includeBlockedBy = filters?.includeBlockedBy === true; const rawSearch = filters?.q?.trim() ?? ""; const hasSearch = rawSearch.length > 0; const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : ""; @@ -1978,7 +1981,9 @@ export function issueService(db: Db) { ? userReadStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), lastActivityStatsForIssues(db, companyId, issueIds), - blockedByMapForIssues(db, companyId, issueIds), + includeBlockedBy + ? blockedByMapForIssues(db, companyId, issueIds) + : Promise.resolve(new Map()), ]); const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); @@ -1994,7 +1999,7 @@ export function issueService(db: Db) { ) ?? row.updatedAt; return { ...row, - blockedBy: blockedByMap.get(row.id) ?? [], + ...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}), lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), }; @@ -2012,7 +2017,7 @@ export function issueService(db: Db) { ) ?? row.updatedAt; return { ...row, - blockedBy: blockedByMap.get(row.id) ?? [], + ...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}), lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), ...deriveIssueUserContext(row, contextUserId, { diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index dedbfa9cd73..6dba7027ec4 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -25,10 +25,10 @@ describe("issuesApi.list", () => { }); it("passes descendantOf through to the company issues endpoint", async () => { - await issuesApi.list("company-1", { descendantOf: "issue-root-1", limit: 25 }); + await issuesApi.list("company-1", { descendantOf: "issue-root-1", includeBlockedBy: true, limit: 25 }); expect(mockApi.get).toHaveBeenCalledWith( - "/companies/company-1/issues?descendantOf=issue-root-1&limit=25", + "/companies/company-1/issues?descendantOf=issue-root-1&includeBlockedBy=true&limit=25", ); }); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index d7e244b25c1..53fb9b92f9f 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -45,6 +45,7 @@ export const issuesApi = { originId?: string; descendantOf?: string; includeRoutineExecutions?: boolean; + includeBlockedBy?: boolean; q?: string; limit?: number; }, @@ -66,6 +67,7 @@ export const issuesApi = { if (filters?.originId) params.set("originId", filters.originId); if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf); if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true"); + if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true"); if (filters?.q) params.set("q", filters.q); if (filters?.limit) params.set("limit", String(filters.limit)); const qs = params.toString(); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 5822ea334ad..48a413a75e7 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -204,7 +204,7 @@ function saveIssueColumns(key: string, columns: InboxIssueColumn[]) { function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { if (state.sortField === "workflow") { const ordered = workflowSort(issues); - return state.sortDir === "desc" ? ordered.reverse() : ordered; + return state.sortDir === "desc" ? [...ordered].reverse() : ordered; } const sorted = [...issues]; const dir = state.sortDir === "asc" ? 1 : -1; @@ -848,9 +848,8 @@ export function IssuesList({ const checklistAffordanceEnabled = useMemo( () => defaultSortField === "workflow" - && viewState.sortField === "workflow" && viewState.groupBy === "none", - [defaultSortField, viewState.groupBy, viewState.sortField], + [defaultSortField, viewState.groupBy], ); const workflowChecklistMeta = useMemo(() => { if (!checklistAffordanceEnabled) return null; diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 6b33e056b6b..f5ae861634d 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1151,7 +1151,7 @@ export function IssueDetail() { issue?.id && resolvedCompanyId ? queryKeys.issues.listByDescendantRoot(resolvedCompanyId, issue.id) : ["issues", "parent", "pending"], - queryFn: () => issuesApi.list(resolvedCompanyId!, { descendantOf: issue!.id }), + queryFn: () => issuesApi.list(resolvedCompanyId!, { descendantOf: issue!.id, includeBlockedBy: true }), enabled: !!resolvedCompanyId && !!issue?.id, placeholderData: keepPreviousDataForSameQueryTail(issue?.id ?? "pending"), }); @@ -3155,7 +3155,7 @@ export function IssueDetail() { projectId={issue.projectId ?? undefined} viewStateKey={`paperclip:issue-detail:${issue.id}:subissues-view`} issueLinkState={resolvedIssueDetailState ?? location.state} - searchFilters={{ descendantOf: issue.id }} + searchFilters={{ descendantOf: issue.id, includeBlockedBy: true }} searchWithinLoadedIssues baseCreateIssueDefaults={buildSubIssueDefaultsForViewer(issue, currentUserId)} createIssueLabel="Sub-issue"