diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx new file mode 100644 index 00000000..4c3a4f72 --- /dev/null +++ b/ui/src/components/IssueProperties.test.tsx @@ -0,0 +1,204 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ComponentProps, ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Issue } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IssueProperties } from "./IssueProperties"; + +const mockAgentsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockProjectsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockIssuesApi = vi.hoisted(() => ({ + listLabels: vi.fn(), +})); + +const mockAuthApi = vi.hoisted(() => ({ + getSession: vi.fn(), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + }), +})); + +vi.mock("../api/agents", () => ({ + agentsApi: mockAgentsApi, +})); + +vi.mock("../api/projects", () => ({ + projectsApi: mockProjectsApi, +})); + +vi.mock("../api/issues", () => ({ + issuesApi: mockIssuesApi, +})); + +vi.mock("../api/auth", () => ({ + authApi: mockAuthApi, +})); + +vi.mock("../hooks/useProjectOrder", () => ({ + useProjectOrder: ({ projects }: { projects: unknown[] }) => ({ + orderedProjects: projects, + }), +})); + +vi.mock("../lib/recent-assignees", () => ({ + getRecentAssigneeIds: () => [], + sortAgentsByRecency: (agents: unknown[]) => agents, + trackRecentAssignee: vi.fn(), +})); + +vi.mock("../lib/assignees", () => ({ + formatAssigneeUserLabel: () => "Me", +})); + +vi.mock("./StatusIcon", () => ({ + StatusIcon: ({ status }: { status: string }) => {status}, +})); + +vi.mock("./PriorityIcon", () => ({ + PriorityIcon: ({ priority }: { priority: string }) => {priority}, +})); + +vi.mock("./Identity", () => ({ + Identity: ({ name }: { name: string }) => {name}, +})); + +vi.mock("./AgentIconPicker", () => ({ + AgentIcon: () => null, +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => {children}, +})); + +vi.mock("@/components/ui/separator", () => ({ + Separator: () =>
, +})); + +vi.mock("@/components/ui/popover", () => ({ + Popover: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Parent issue", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: "user-1", + issueNumber: 1, + identifier: "PAP-1", + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + labels: [], + labelIds: [], + blockedBy: [], + blocks: [], + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:05:00.000Z"), + ...overrides, + }; +} + +function renderProperties(container: HTMLDivElement, props: ComponentProps) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + const root = createRoot(container); + act(() => { + root.render( + + + , + ); + }); + return root; +} + +describe("IssueProperties", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockAgentsApi.list.mockResolvedValue([]); + mockProjectsApi.list.mockResolvedValue([]); + mockIssuesApi.listLabels.mockResolvedValue([]); + mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } }); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("always exposes the add sub-issue action", async () => { + const onAddSubIssue = vi.fn(); + const root = renderProperties(container, { + issue: createIssue(), + childIssues: [], + onAddSubIssue, + onUpdate: vi.fn(), + }); + await flush(); + + expect(container.textContent).toContain("Sub-issues"); + expect(container.textContent).toContain("Add sub-issue"); + + const addButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Add sub-issue")); + expect(addButton).not.toBeUndefined(); + + await act(async () => { + addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onAddSubIssue).toHaveBeenCalledTimes(1); + + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 6c204eab..397f930c 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -72,6 +72,8 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo interface IssuePropertiesProps { issue: Issue; + childIssues?: Issue[]; + onAddSubIssue?: () => void; onUpdate: (data: Record) => void; inline?: boolean; } @@ -147,7 +149,13 @@ function PropertyPicker({ ); } -export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) { +export function IssueProperties({ + issue, + childIssues = [], + onAddSubIssue, + onUpdate, + inline, +}: IssuePropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); const companyId = issue.companyId ?? selectedCompanyId; @@ -713,6 +721,34 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp )} + +
+ {childIssues.length > 0 ? ( + childIssues.map((child) => ( + + {child.identifier ?? child.title} + + )) + ) : ( + None + )} + {onAddSubIssue ? ( + + ) : null} +
+
+ {issue.parentId && ( ({ + newIssueOpen: true, + newIssueDefaults: {} as Record, + closeNewIssue: vi.fn(), +})); + +const companyState = vi.hoisted(() => ({ + companies: [ + { + id: "company-1", + name: "Paperclip", + status: "active", + brandColor: "#123456", + issuePrefix: "PAP", + }, + ], + selectedCompanyId: "company-1", + selectedCompany: { + id: "company-1", + name: "Paperclip", + status: "active", + brandColor: "#123456", + issuePrefix: "PAP", + }, +})); + +const toastState = vi.hoisted(() => ({ + pushToast: vi.fn(), +})); + +const mockIssuesApi = vi.hoisted(() => ({ + create: vi.fn(), + upsertDocument: vi.fn(), + uploadAttachment: vi.fn(), +})); + +const mockExecutionWorkspacesApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockProjectsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockAgentsApi = vi.hoisted(() => ({ + list: vi.fn(), + adapterModels: vi.fn(), +})); + +const mockAuthApi = vi.hoisted(() => ({ + getSession: vi.fn(), +})); + +const mockAssetsApi = vi.hoisted(() => ({ + uploadImage: vi.fn(), +})); + +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); + +vi.mock("../context/DialogContext", () => ({ + useDialog: () => dialogState, +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => companyState, +})); + +vi.mock("../context/ToastContext", () => ({ + useToast: () => toastState, +})); + +vi.mock("../api/issues", () => ({ + issuesApi: mockIssuesApi, +})); + +vi.mock("../api/execution-workspaces", () => ({ + executionWorkspacesApi: mockExecutionWorkspacesApi, +})); + +vi.mock("../api/projects", () => ({ + projectsApi: mockProjectsApi, +})); + +vi.mock("../api/agents", () => ({ + agentsApi: mockAgentsApi, +})); + +vi.mock("../api/auth", () => ({ + authApi: mockAuthApi, +})); + +vi.mock("../api/assets", () => ({ + assetsApi: mockAssetsApi, +})); + +vi.mock("../api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + +vi.mock("../hooks/useProjectOrder", () => ({ + useProjectOrder: ({ projects }: { projects: unknown[] }) => ({ + orderedProjects: projects, + }), +})); + +vi.mock("../lib/recent-assignees", () => ({ + getRecentAssigneeIds: () => [], + sortAgentsByRecency: (agents: unknown[]) => agents, + trackRecentAssignee: vi.fn(), +})); + +vi.mock("../lib/assignees", () => ({ + assigneeValueFromSelection: ({ + assigneeAgentId, + assigneeUserId, + }: { + assigneeAgentId?: string; + assigneeUserId?: string; + }) => assigneeAgentId ? `agent:${assigneeAgentId}` : assigneeUserId ? `user:${assigneeUserId}` : "", + currentUserAssigneeOption: () => [], + parseAssigneeValue: (value: string) => ({ + assigneeAgentId: value.startsWith("agent:") ? value.slice("agent:".length) : null, + assigneeUserId: value.startsWith("user:") ? value.slice("user:".length) : null, + }), +})); + +vi.mock("./MarkdownEditor", async () => { + const React = await import("react"); + return { + MarkdownEditor: React.forwardRef< + { focus: () => void }, + { value: string; onChange?: (value: string) => void; placeholder?: string } + >(function MarkdownEditorMock({ value, onChange, placeholder }, ref) { + React.useImperativeHandle(ref, () => ({ + focus: () => undefined, + })); + return ( +