Make issue detail subissues list match issues list

This commit is contained in:
Dotta committed 2026-04-14 11:46:17 -05:00
1 parent 22dab80439
commit 0378c1afba
3 files changed
+133 -71

No files matched your search

+73
View File
@@ -226,6 +226,79 @@ describe("IssuesList", () => {
});
});
it("keeps server-side search scoped to the provided parent issue filters", 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" });
mockIssuesApi.list.mockResolvedValue([serverIssue]);
const { root } = renderWithQueryClient(
<IssuesList
issues={[localIssue]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
initialSearch="server"
searchFilters={{ parentId: "parent-1" }}
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", {
q: "server",
projectId: undefined,
parentId: "parent-1",
});
expect(container.textContent).toContain("Server result");
expect(container.textContent).not.toContain("Local issue");
});
act(() => {
root.unmount();
});
});
it("uses the supplied create defaults and label for sub-issue lists", async () => {
const { root } = renderWithQueryClient(
<IssuesList
issues={[createIssue()]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
baseCreateIssueDefaults={{ parentId: "parent-1", projectId: "project-1" }}
createIssueLabel="Sub-issue"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
const button = Array.from(container.querySelectorAll("button")).find(
(candidate) => candidate.textContent?.includes("New Sub-issue"),
);
expect(button).not.toBeUndefined();
});
await act(async () => {
const button = Array.from(container.querySelectorAll("button")).find(
(candidate) => candidate.textContent?.includes("New Sub-issue"),
);
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(dialogState.openNewIssue).toHaveBeenCalledWith({
parentId: "parent-1",
projectId: "project-1",
});
act(() => {
root.unmount();
});
});
it("debounces search updates so typing does not notify the page on every keystroke", async () => {
vi.useFakeTimers();
+20 -11
View File
@@ -119,6 +119,7 @@ interface Agent {
}
type ProjectOption = Pick<Project, "id" | "name"> & Partial<Pick<Project, "color" | "workspaces" | "executionWorkspacePolicy" | "primaryWorkspace">>;
type IssueListRequestFilters = NonNullable<Parameters<typeof issuesApi.list>[1]>;
interface IssuesListProps {
issues: Issue[];
@@ -132,9 +133,9 @@ interface IssuesListProps {
issueLinkState?: unknown;
initialAssignees?: string[];
initialSearch?: string;
searchFilters?: {
participantAgentId?: string;
};
searchFilters?: Omit<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
baseCreateIssueDefaults?: Record<string, unknown>;
createIssueLabel?: string;
enableRoutineVisibilityFilter?: boolean;
onSearchChange?: (search: string) => void;
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
@@ -215,6 +216,8 @@ export function IssuesList({
initialAssignees,
initialSearch,
searchFilters,
baseCreateIssueDefaults,
createIssueLabel,
enableRoutineVisibilityFilter = false,
onSearchChange,
onUpdateIssue,
@@ -485,8 +488,8 @@ export function IssuesList({
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]);
const newIssueDefaults = useCallback((groupKey?: string) => {
const defaults: Record<string, string> = {};
if (projectId) defaults.projectId = projectId;
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
if (projectId && defaults.projectId === undefined) defaults.projectId = projectId;
if (groupKey) {
if (viewState.groupBy === "status") defaults.status = groupKey;
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
@@ -501,7 +504,13 @@ export function IssuesList({
}
}
return defaults;
}, [currentUserId, issueById, projectId, viewState.groupBy]);
}, [baseCreateIssueDefaults, currentUserId, issueById, projectId, viewState.groupBy]);
const createActionLabel = createIssueLabel ? `Create ${createIssueLabel}` : "Create Issue";
const createButtonLabel = createIssueLabel ? `New ${createIssueLabel}` : "New Issue";
const openCreateIssueDialog = useCallback((groupKey?: string) => {
openNewIssue(newIssueDefaults(groupKey));
}, [newIssueDefaults, openNewIssue]);
const filterToWorkspace = useCallback((workspaceId: string) => {
updateView({ workspaces: [workspaceId] });
@@ -533,9 +542,9 @@ export function IssuesList({
{/* Toolbar */}
<div className="flex items-center justify-between gap-2 sm:gap-3">
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
<Button size="sm" variant="outline" onClick={() => openNewIssue(newIssueDefaults())}>
<Button size="sm" variant="outline" onClick={() => openCreateIssueDialog()}>
<Plus className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">New Issue</span>
<span className="hidden sm:inline">{createButtonLabel}</span>
</Button>
<IssueSearchInput
value={issueSearch}
@@ -673,8 +682,8 @@ export function IssuesList({
<EmptyState
icon={CircleDot}
message="No issues match the current filters or search."
action="Create Issue"
onAction={() => openNewIssue(newIssueDefaults())}
action={createActionLabel}
onAction={() => openCreateIssueDialog()}
/>
)}
@@ -710,7 +719,7 @@ export function IssuesList({
variant="ghost"
size="icon-xs"
className="ml-auto text-muted-foreground"
onClick={() => openNewIssue(newIssueDefaults(group.key))}
onClick={() => openCreateIssueDialog(group.key)}
>
<Plus className="h-3 w-3" />
</Button>
+40 -60
View File
@@ -58,6 +58,7 @@ import { InlineEditor } from "../components/InlineEditor";
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
import { useLiveRunTranscripts } from "../components/transcript/useLiveRunTranscripts";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
import { IssuesList } from "../components/IssuesList";
import { IssueProperties } from "../components/IssueProperties";
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
import type { MentionOption } from "../components/MarkdownEditor";
@@ -88,7 +89,6 @@ import {
Copy,
EyeOff,
Hexagon,
ListTree,
MessageSquare,
MoreHorizontal,
MoreVertical,
@@ -1012,6 +1012,26 @@ export function IssueDetail() {
updateIssue.mutate(data);
}, [updateIssue.mutate]);
const updateChildIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
onSuccess: () => {
if (resolvedCompanyId) {
queryClient.invalidateQueries({ queryKey: ["issues", resolvedCompanyId] });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(resolvedCompanyId) });
}
},
onError: (err) => {
pushToast({
title: "Issue update failed",
body: err instanceof Error ? err.message : "Unable to save sub-issue changes",
tone: "error",
});
},
});
const handleChildIssueUpdate = useCallback((id: string, data: Record<string, unknown>) => {
updateChildIssue.mutate({ id, data });
}, [updateChildIssue]);
const approvalDecision = useMutation({
mutationFn: async ({ approvalId, action }: { approvalId: string; action: "approve" | "reject" }) => {
if (action === "approve") {
@@ -2097,54 +2117,25 @@ export function IssueDetail() {
missingBehavior="placeholder"
/>
{(childIssuesLoading || childIssues.length > 0) && (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Sub-issues</h3>
<Button variant="outline" size="sm" onClick={openNewSubIssue} className="shadow-none">
<ListTree className="h-3.5 w-3.5 mr-1.5" />
<span className="hidden sm:inline">Add sub-issue</span>
<span className="sm:hidden">Sub-issue</span>
</Button>
</div>
{childIssuesLoading ? (
<IssueSectionSkeleton titleWidth="w-24" rows={2} />
) : (
<div className="border border-border rounded-lg divide-y divide-border">
{childIssues.map((child) => (
<Link
key={child.id}
to={createIssueDetailPath(child.identifier ?? child.id)}
state={resolvedIssueDetailState ?? location.state}
issuePrefetch={child}
onClickCapture={() =>
rememberIssueDetailLocationState(
child.identifier ?? child.id,
resolvedIssueDetailState ?? location.state,
location.search,
)}
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
>
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={child.status} />
<PriorityIcon priority={child.priority} />
<span className="font-mono text-muted-foreground shrink-0">
{child.identifier ?? child.id.slice(0, 8)}
</span>
<span className="truncate">{child.title}</span>
</div>
{child.assigneeAgentId && (() => {
const name = agentMap.get(child.assigneeAgentId)?.name;
return name
? <Identity name={name} size="sm" />
: <span className="text-muted-foreground font-mono">{child.assigneeAgentId.slice(0, 8)}</span>;
})()}
</Link>
))}
</div>
)}
<<<<<<< HEAD
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Sub-issues</h3>
</div>
)}
<IssuesList
issues={childIssues}
isLoading={childIssuesLoading}
agents={agents}
projects={projects}
projectId={issue.projectId ?? undefined}
viewStateKey={`paperclip:issue-detail:${issue.id}:subissues-view`}
issueLinkState={resolvedIssueDetailState ?? location.state}
searchFilters={{ parentId: issue.id }}
baseCreateIssueDefaults={buildSubIssueDefaultsForViewer(issue, currentUserId)}
createIssueLabel="Sub-issue"
onUpdateIssue={handleChildIssueUpdate}
/>
</div>
<IssueDocumentsSection
issue={issue}
@@ -2167,18 +2158,7 @@ export function IssueDetail() {
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
});
}}
extraActions={
<>
{!hasAttachments && attachmentUploadButton}
{childIssues.length === 0 && (
<Button variant="outline" size="sm" onClick={openNewSubIssue} className="shadow-none">
<ListTree className="h-3.5 w-3.5 mr-1.5" />
<span className="hidden sm:inline">Add sub-issue</span>
<span className="sm:hidden">Sub-issue</span>
</Button>
)}
</>
}
extraActions={!hasAttachments ? attachmentUploadButton : null}
/>
{attachmentsInitialLoading ? (