mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Make issue detail subissues list match issues list
This commit is contained in:
1 parent
22dab80439
commit
0378c1afba
3 files changed
+133
-71
No files matched your search
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user