Fix external object issue filtering

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-23 00:35:05 +00:00
1 parent 8e279837fc
commit 71d08a6e05
8 files changed
+317 -58

No files matched your search

+56
View File
@@ -355,6 +355,62 @@ describe("IssuesList", () => {
});
});
it("filters issue rows by external-object status summaries", async () => {
const failedIssue = createIssue({ id: "issue-failed", identifier: "PAP-10", title: "Failed external object" });
const freshIssue = createIssue({ id: "issue-fresh", identifier: "PAP-11", title: "Fresh external object" });
const noObjectIssue = createIssue({ id: "issue-none", identifier: "PAP-12", title: "No external object" });
localStorage.setItem("paperclip:test-issues:company-1", JSON.stringify({ externalObjectStatuses: ["failed"] }));
mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({
summaries: {
"issue-failed": {
total: 1,
byStatusCategory: { failed: 1 },
byLiveness: { fresh: 1 },
highestSeverity: "danger",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
},
"issue-fresh": {
total: 1,
byStatusCategory: { succeeded: 1 },
byLiveness: { fresh: 1 },
highestSeverity: "success",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
},
},
});
const { root } = renderWithQueryClient(
<IssuesList
issues={[failedIssue, freshIssue, noObjectIssue]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledWith(
"company-1",
["issue-failed", "issue-fresh", "issue-none"],
);
expect(container.textContent).toContain("Failed external object");
expect(container.textContent).not.toContain("Fresh external object");
expect(container.textContent).not.toContain("No external object");
});
act(() => {
root.unmount();
});
});
it("renders server search results instead of filtering the full issue list locally", async () => {
const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" });
const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" });
+34 -20
View File
@@ -968,43 +968,57 @@ export function IssuesList({
[boardIssueQueries, searchWithinLoadedIssues, viewState.viewMode],
);
const filtered = useMemo(() => {
const sourceIssues = useMemo(() => {
const useRemoteSearch = normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues;
const sourceIssues = boardIssues ?? (useRemoteSearch ? searchedIssues : issues);
const searchScopedIssues = normalizedIssueSearch.length > 0 && searchWithinLoadedIssues
return boardIssues ?? (useRemoteSearch ? searchedIssues : issues);
}, [boardIssues, issues, normalizedIssueSearch, searchedIssues, searchWithinLoadedIssues]);
const searchScopedIssues = useMemo(
() => normalizedIssueSearch.length > 0 && searchWithinLoadedIssues
? sourceIssues.filter((issue) => issueMatchesLocalSearch(issue, normalizedIssueSearch))
: sourceIssues;
: sourceIssues,
[normalizedIssueSearch, searchWithinLoadedIssues, sourceIssues],
);
const hasExternalObjectStatusFilters = viewState.externalObjectStatuses.length > 0;
const issueIdsForExternalObjectSummaries = useMemo(
() => (viewState.viewMode === "list" || hasExternalObjectStatusFilters
? searchScopedIssues.map((issue) => issue.id)
: []),
[hasExternalObjectStatusFilters, searchScopedIssues, viewState.viewMode],
);
const {
summaries: externalObjectSummaryByIssueId,
isLoading: externalObjectSummariesLoading,
isReady: externalObjectSummariesReady,
} = useIssueExternalObjectSummaries(
selectedCompanyId,
issueIdsForExternalObjectSummaries,
);
const issueFilterContext = useMemo(() => ({
...issueFilterWorkspaceContext,
externalObjectSummaryByIssueId,
externalObjectSummariesReady: externalObjectSummariesReady && !externalObjectSummariesLoading,
}), [externalObjectSummariesLoading, externalObjectSummariesReady, externalObjectSummaryByIssueId, issueFilterWorkspaceContext]);
const filtered = useMemo(() => {
const filteredByControls = applyIssueFilters(
searchScopedIssues,
viewState,
currentUserId,
enableRoutineVisibilityFilter,
liveIssueIds,
issueFilterWorkspaceContext,
issueFilterContext,
);
return sortIssues(filteredByControls, viewState);
}, [
boardIssues,
issues,
searchedIssues,
searchWithinLoadedIssues,
searchScopedIssues,
viewState,
normalizedIssueSearch,
currentUserId,
enableRoutineVisibilityFilter,
liveIssueIds,
issueFilterWorkspaceContext,
issueFilterContext,
]);
const issueIdsForExternalObjectSummaries = useMemo(
() => (viewState.viewMode === "list" ? filtered.map((issue) => issue.id) : []),
[filtered, viewState.viewMode],
);
const { summaries: externalObjectSummaryByIssueId } = useIssueExternalObjectSummaries(
selectedCompanyId,
issueIdsForExternalObjectSummaries,
);
const progressSummary = useMemo(
() => shouldRenderSubIssueProgressSummary(showProgressSummary, issues.length)
? buildSubIssueProgressSummary(issues)
+2
View File
@@ -165,6 +165,7 @@ export function useIssueExternalObjectSummaries(
): {
summaries: Map<string, ExternalObjectSummary>;
isLoading: boolean;
isReady: boolean;
} {
const normalizedIssueIds = useMemo(
() => [...new Set(issueIds.filter((issueId) => issueId.length > 0))].sort(),
@@ -184,6 +185,7 @@ export function useIssueExternalObjectSummaries(
return {
summaries,
isLoading: enabled && query.isLoading,
isReady: !enabled || query.isSuccess,
};
}
+53
View File
@@ -5,6 +5,7 @@ import type {
Approval,
DashboardSummary,
ExecutionWorkspace,
ExternalObjectSummary,
HeartbeatRun,
Issue,
JoinRequest,
@@ -958,6 +959,58 @@ describe("inbox helpers", () => {
).toEqual([]);
});
it("applies external-object filters to remote inbox search supplements", () => {
const failedMatch = makeIssue("failed-match", false);
const freshMatch = makeIssue("fresh-match", false);
const summaries = new Map<string, ExternalObjectSummary>([
["failed-match", {
total: 1,
byStatusCategory: { failed: 1 },
byLiveness: { fresh: 1 },
highestSeverity: "danger",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
}],
["fresh-match", {
total: 1,
byStatusCategory: { succeeded: 1 },
byLiveness: { fresh: 1 },
highestSeverity: "success",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
}],
]);
expect(
getInboxSearchSupplementIssues({
query: "github",
filteredWorkItems: [],
archivedSearchIssues: [],
remoteIssues: [failedMatch, freshMatch],
issueFilters: {
statuses: [],
priorities: [],
assignees: [],
creators: [],
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
externalObjectStatuses: ["failed"],
hideRoutineExecutions: true,
},
issueFilterContext: {
externalObjectSummaryByIssueId: summaries,
externalObjectSummariesReady: true,
},
}).map((issue) => issue.id),
).toEqual(["failed-match"]);
});
it("keeps inbox search matches ahead of archived and other result sections", () => {
const inboxIssue = makeIssue("inbox", false);
inboxIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
+11 -1
View File
@@ -11,6 +11,7 @@ import {
defaultIssueFilterState,
normalizeIssueFilterState,
type IssueFilterState,
type IssueFilterWorkspaceContext,
} from "./issue-filters";
import { formatAssigneeUserLabel } from "./assignees";
@@ -462,6 +463,7 @@ export function getInboxSearchSupplementIssues({
currentUserId,
enableRoutineVisibilityFilter = false,
liveIssueIds,
issueFilterContext = {},
}: {
query: string;
filteredWorkItems: InboxWorkItem[];
@@ -471,6 +473,7 @@ export function getInboxSearchSupplementIssues({
currentUserId?: string | null;
enableRoutineVisibilityFilter?: boolean;
liveIssueIds?: ReadonlySet<string>;
issueFilterContext?: IssueFilterWorkspaceContext;
}): Issue[] {
const normalizedQuery = query.trim();
if (!normalizedQuery) return [];
@@ -480,7 +483,14 @@ export function getInboxSearchSupplementIssues({
.map((item) => item.issue.id),
...archivedSearchIssues.map((issue) => issue.id),
]);
return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter, liveIssueIds)
return applyIssueFilters(
remoteIssues,
issueFilters,
currentUserId,
enableRoutineVisibilityFilter,
liveIssueIds,
issueFilterContext,
)
.filter((issue) => !visibleIssueIds.has(issue.id));
}
+73 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import type { Issue } from "@paperclipai/shared";
import type { ExternalObjectSummary, Issue } from "@paperclipai/shared";
import {
applyIssueFilters,
countActiveIssueFilters,
@@ -51,6 +51,20 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
};
}
function makeExternalObjectSummary(overrides: Partial<ExternalObjectSummary> = {}): ExternalObjectSummary {
return {
total: 1,
byStatusCategory: {},
byLiveness: { fresh: 1 },
highestSeverity: "neutral",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
...overrides,
};
}
describe("issue filters", () => {
it("filters issues by creator across agents and users", () => {
const issues = [
@@ -169,4 +183,62 @@ describe("issue filters", () => {
new Set(["workspace-default"]),
)).toBe(true);
});
it.each([
["failed", ["failed-issue", "blocked-issue"]],
["auth_required", ["auth-issue"]],
["stale", ["stale-issue"]],
["none", ["none-issue"]],
])("filters issues by external-object status token %s", (externalObjectStatus, expectedIssueIds) => {
const issues = [
makeIssue({ id: "failed-issue" }),
makeIssue({ id: "blocked-issue" }),
makeIssue({ id: "auth-issue" }),
makeIssue({ id: "stale-issue" }),
makeIssue({ id: "none-issue" }),
makeIssue({ id: "fresh-issue" }),
];
const summaries = new Map<string, ExternalObjectSummary>([
["failed-issue", makeExternalObjectSummary({ byStatusCategory: { failed: 1 }, highestSeverity: "danger" })],
["blocked-issue", makeExternalObjectSummary({ byStatusCategory: { blocked: 1 }, highestSeverity: "danger" })],
["auth-issue", makeExternalObjectSummary({
byLiveness: { auth_required: 1 },
highestSeverity: "danger",
authRequiredCount: 1,
})],
["stale-issue", makeExternalObjectSummary({
byLiveness: { stale: 1 },
highestSeverity: "warning",
staleCount: 1,
})],
["fresh-issue", makeExternalObjectSummary({ byStatusCategory: { succeeded: 1 }, highestSeverity: "success" })],
]);
const filtered = applyIssueFilters(
issues,
{ ...defaultIssueFilterState, externalObjectStatuses: [externalObjectStatus] },
null,
false,
undefined,
{
externalObjectSummaryByIssueId: summaries,
externalObjectSummariesReady: true,
},
);
expect(filtered.map((issue) => issue.id)).toEqual(expectedIssueIds);
});
it("does not apply external-object filters before summaries are ready", () => {
const filtered = applyIssueFilters(
[makeIssue({ id: "issue-1" })],
{ ...defaultIssueFilterState, externalObjectStatuses: ["none"] },
null,
false,
undefined,
{ externalObjectSummaryByIssueId: new Map(), externalObjectSummariesReady: false },
);
expect(filtered).toEqual([]);
});
});
+46 -1
View File
@@ -1,4 +1,4 @@
import type { Issue } from "@paperclipai/shared";
import type { ExternalObjectSummary, Issue } from "@paperclipai/shared";
export type IssueFilterWorkspaceLookup = {
mode?: string | null;
@@ -8,6 +8,8 @@ export type IssueFilterWorkspaceLookup = {
export type IssueFilterWorkspaceContext = {
executionWorkspaceById?: ReadonlyMap<string, IssueFilterWorkspaceLookup>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
externalObjectSummaryByIssueId?: ReadonlyMap<string, ExternalObjectSummary>;
externalObjectSummariesReady?: boolean;
};
export type IssueFilterState = {
@@ -159,6 +161,39 @@ export function shouldIncludeIssueFilterWorkspaceOption(
&& defaultProjectWorkspaceIds.has(workspace.projectWorkspaceId));
}
function summaryRecordCount(record: Record<string, number> | undefined, key: string): number {
return record?.[key] ?? 0;
}
function issueMatchesExternalObjectStatusFilter(
summary: ExternalObjectSummary | null | undefined,
value: string,
): boolean {
const total = summary?.total ?? 0;
switch (value) {
case "failed":
return summaryRecordCount(summary?.byStatusCategory, "failed") > 0
|| summaryRecordCount(summary?.byStatusCategory, "blocked") > 0;
case "waiting":
return summaryRecordCount(summary?.byStatusCategory, "waiting") > 0;
case "running":
return summaryRecordCount(summary?.byStatusCategory, "running") > 0;
case "auth_required":
return (summary?.authRequiredCount ?? 0) > 0
|| summaryRecordCount(summary?.byLiveness, "auth_required") > 0;
case "unreachable":
return (summary?.unreachableCount ?? 0) > 0
|| summaryRecordCount(summary?.byLiveness, "unreachable") > 0;
case "stale":
return (summary?.staleCount ?? 0) > 0
|| summaryRecordCount(summary?.byLiveness, "stale") > 0;
case "none":
return total === 0;
default:
return false;
}
}
export function applyIssueFilters(
issues: Issue[],
state: IssueFilterState,
@@ -207,6 +242,16 @@ export function applyIssueFilters(
return workspaceId != null && state.workspaces.includes(workspaceId);
});
}
if (state.externalObjectStatuses.length > 0) {
const summaries = workspaceContext.externalObjectSummaryByIssueId;
if (!summaries || workspaceContext.externalObjectSummariesReady !== true) return [];
result = result.filter((issue) => {
const summary = summaries.get(issue.id) ?? null;
return state.externalObjectStatuses.some((status) =>
issueMatchesExternalObjectStatusFilter(summary, status),
);
});
}
return result;
}
+42 -35
View File
@@ -864,13 +864,49 @@ export function Inbox() {
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
const shouldUseIssueSearchSupplement =
!!selectedCompanyId
&& normalizedSearchQuery.length > 0;
const { data: remoteIssueSearchResults = [] } = useQuery({
queryKey: [
...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25),
"inbox-supplement",
],
queryFn: () =>
issuesApi.list(selectedCompanyId!, {
q: normalizedSearchQuery,
limit: 25,
includeRoutineExecutions: true,
}),
enabled: shouldUseIssueSearchSupplement,
placeholderData: (previousData) => previousData,
});
const inboxIssueIdsForExternalObjectSummaries = useMemo(() => {
const issueIds = new Set<string>();
for (const issue of mineIssues) issueIds.add(issue.id);
for (const issue of touchedIssues) issueIds.add(issue.id);
for (const issue of remoteIssueSearchResults) issueIds.add(issue.id);
return [...issueIds];
}, [mineIssues, remoteIssueSearchResults, touchedIssues]);
const {
summaries: externalObjectSummaryByIssueId,
isLoading: externalObjectSummariesLoading,
isReady: externalObjectSummariesReady,
} = useIssueExternalObjectSummaries(
selectedCompanyId,
inboxIssueIdsForExternalObjectSummaries,
);
const issueFilterContext = useMemo(() => ({
externalObjectSummaryByIssueId,
externalObjectSummariesReady: externalObjectSummariesReady && !externalObjectSummariesLoading,
}), [externalObjectSummariesLoading, externalObjectSummariesReady, externalObjectSummaryByIssueId]);
const visibleMineIssues = useMemo(
() => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds),
[mineIssues, issueFilters, currentUserId, liveIssueIds],
() => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext),
[mineIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext],
);
const visibleTouchedIssues = useMemo(
() => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds),
[touchedIssues, issueFilters, currentUserId, liveIssueIds],
() => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext),
[touchedIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext],
);
const unreadTouchedIssues = useMemo(
() => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe),
@@ -1161,23 +1197,6 @@ export function Inbox() {
visibleTouchedIssues,
],
);
const shouldUseIssueSearchSupplement =
!!selectedCompanyId
&& normalizedSearchQuery.length > 0;
const { data: remoteIssueSearchResults = [] } = useQuery({
queryKey: [
...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25),
"inbox-supplement",
],
queryFn: () =>
issuesApi.list(selectedCompanyId!, {
q: normalizedSearchQuery,
limit: 25,
includeRoutineExecutions: true,
}),
enabled: shouldUseIssueSearchSupplement,
placeholderData: (previousData) => previousData,
});
const issueSearchSupplementResults = useMemo(
() =>
getInboxSearchSupplementIssues({
@@ -1189,11 +1208,13 @@ export function Inbox() {
currentUserId,
enableRoutineVisibilityFilter: true,
liveIssueIds,
issueFilterContext,
}),
[
archivedSearchIssues,
currentUserId,
filteredWorkItems,
issueFilterContext,
issueFilters,
liveIssueIds,
normalizedSearchQuery,
@@ -1277,20 +1298,6 @@ export function Inbox() {
if (!defaults) return;
openNewIssue(defaults);
}, [groupBy, inboxWorkspaceGrouping, openNewIssue]);
const inboxIssueIdsForExternalObjectSummaries = useMemo(() => {
const issueIds = new Set<string>();
for (const section of groupedSections) {
for (const item of section.displayItems) {
if (item.kind === "issue") issueIds.add(item.issue.id);
}
}
return [...issueIds];
}, [groupedSections]);
const { summaries: externalObjectSummaryByIssueId } = useIssueExternalObjectSummaries(
selectedCompanyId,
inboxIssueIdsForExternalObjectSummaries,
);
const totalVisibleWorkItems = useMemo(
() => groupedSections.reduce((count, group) => count + group.displayItems.length, 0),
[groupedSections],