mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
Add sub-issue issue-page flows
This commit is contained in:
parent
365b6d9bd8
commit
977e9f3e9a
6 changed files with 665 additions and 8 deletions
204
ui/src/components/IssueProperties.test.tsx
Normal file
204
ui/src/components/IssueProperties.test.tsx
Normal file
|
|
@ -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 }) => <span>{status}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./PriorityIcon", () => ({
|
||||||
|
PriorityIcon: ({ priority }: { priority: string }) => <span>{priority}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./Identity", () => ({
|
||||||
|
Identity: ({ name }: { name: string }) => <span>{name}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./AgentIconPicker", () => ({
|
||||||
|
AgentIcon: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/router", () => ({
|
||||||
|
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => <a href={to} {...props}>{children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/separator", () => ({
|
||||||
|
Separator: () => <hr />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/popover", () => ({
|
||||||
|
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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> = {}): 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<typeof IssueProperties>) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<IssueProperties {...props} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -72,6 +72,8 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
|
||||||
|
|
||||||
interface IssuePropertiesProps {
|
interface IssuePropertiesProps {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
|
childIssues?: Issue[];
|
||||||
|
onAddSubIssue?: () => void;
|
||||||
onUpdate: (data: Record<string, unknown>) => void;
|
onUpdate: (data: Record<string, unknown>) => void;
|
||||||
inline?: boolean;
|
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 { selectedCompanyId } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const companyId = issue.companyId ?? selectedCompanyId;
|
const companyId = issue.companyId ?? selectedCompanyId;
|
||||||
|
|
@ -713,6 +721,34 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||||
)}
|
)}
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
|
|
||||||
|
<PropertyRow label="Sub-issues">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{childIssues.length > 0 ? (
|
||||||
|
childIssues.map((child) => (
|
||||||
|
<Link
|
||||||
|
key={child.id}
|
||||||
|
to={`/issues/${child.identifier ?? child.id}`}
|
||||||
|
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
{child.identifier ?? child.title}
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">None</span>
|
||||||
|
)}
|
||||||
|
{onAddSubIssue ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
|
onClick={onAddSubIssue}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
Add sub-issue
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</PropertyRow>
|
||||||
|
|
||||||
{issue.parentId && (
|
{issue.parentId && (
|
||||||
<PropertyRow label="Parent">
|
<PropertyRow label="Parent">
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
348
ui/src/components/NewIssueDialog.test.tsx
Normal file
348
ui/src/components/NewIssueDialog.test.tsx
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
// @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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { NewIssueDialog } from "./NewIssueDialog";
|
||||||
|
|
||||||
|
const dialogState = vi.hoisted(() => ({
|
||||||
|
newIssueOpen: true,
|
||||||
|
newIssueDefaults: {} as Record<string, unknown>,
|
||||||
|
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 (
|
||||||
|
<textarea
|
||||||
|
aria-label={placeholder ?? "Description"}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange?.(event.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./InlineEntitySelector", async () => {
|
||||||
|
const React = await import("react");
|
||||||
|
return {
|
||||||
|
InlineEntitySelector: React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
{
|
||||||
|
value: string;
|
||||||
|
placeholder?: string;
|
||||||
|
renderTriggerValue?: (option: { id: string; label: string } | null) => ReactNode;
|
||||||
|
}
|
||||||
|
>(function InlineEntitySelectorMock({ value, placeholder, renderTriggerValue }, ref) {
|
||||||
|
return (
|
||||||
|
<button ref={ref} type="button">
|
||||||
|
{(renderTriggerValue?.(value ? { id: value, label: value } : null) ?? value) || placeholder}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./AgentIconPicker", () => ({
|
||||||
|
AgentIcon: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/dialog", () => ({
|
||||||
|
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? <div>{children}</div> : null),
|
||||||
|
DialogContent: ({
|
||||||
|
children,
|
||||||
|
showCloseButton: _showCloseButton,
|
||||||
|
onEscapeKeyDown: _onEscapeKeyDown,
|
||||||
|
onPointerDownOutside: _onPointerDownOutside,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
onEscapeKeyDown?: (event: unknown) => void;
|
||||||
|
onPointerDownOutside?: (event: unknown) => void;
|
||||||
|
}) => <div {...props}>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/button", () => ({
|
||||||
|
Button: ({ children, onClick, type = "button", ...props }: ComponentProps<"button">) => (
|
||||||
|
<button type={type} onClick={onClick} {...props}>{children}</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/toggle-switch", () => ({
|
||||||
|
ToggleSwitch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: () => void }) => (
|
||||||
|
<button type="button" aria-pressed={checked} onClick={onCheckedChange}>toggle</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/popover", () => ({
|
||||||
|
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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 renderDialog(container: HTMLDivElement) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<NewIssueDialog />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return { root, queryClient };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("NewIssueDialog", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
dialogState.newIssueOpen = true;
|
||||||
|
dialogState.newIssueDefaults = {};
|
||||||
|
dialogState.closeNewIssue.mockReset();
|
||||||
|
toastState.pushToast.mockReset();
|
||||||
|
mockIssuesApi.create.mockReset();
|
||||||
|
mockIssuesApi.upsertDocument.mockReset();
|
||||||
|
mockIssuesApi.uploadAttachment.mockReset();
|
||||||
|
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||||
|
mockProjectsApi.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project-1",
|
||||||
|
name: "Alpha",
|
||||||
|
description: null,
|
||||||
|
archivedAt: null,
|
||||||
|
color: "#445566",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockAgentsApi.list.mockResolvedValue([]);
|
||||||
|
mockAgentsApi.adapterModels.mockResolvedValue([]);
|
||||||
|
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
|
||||||
|
mockAssetsApi.uploadImage.mockResolvedValue({ contentPath: "/uploads/asset.png" });
|
||||||
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||||
|
mockIssuesApi.create.mockResolvedValue({
|
||||||
|
id: "issue-2",
|
||||||
|
companyId: "company-1",
|
||||||
|
identifier: "PAP-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows sub-issue context only when opened from a sub-issue action", async () => {
|
||||||
|
dialogState.newIssueDefaults = {
|
||||||
|
parentId: "issue-1",
|
||||||
|
parentIdentifier: "PAP-1",
|
||||||
|
parentTitle: "Parent issue",
|
||||||
|
projectId: "project-1",
|
||||||
|
goalId: "goal-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { root } = renderDialog(container);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("New sub-issue");
|
||||||
|
expect(container.textContent).toContain("Sub-issue of");
|
||||||
|
expect(container.textContent).toContain("PAP-1");
|
||||||
|
expect(container.textContent).toContain("Parent issue");
|
||||||
|
expect(container.textContent).toContain("Create Sub-Issue");
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
|
||||||
|
dialogState.newIssueDefaults = {};
|
||||||
|
const rerendered = renderDialog(container);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("New issue");
|
||||||
|
expect(container.textContent).toContain("Create Issue");
|
||||||
|
expect(container.textContent).not.toContain("Sub-issue of");
|
||||||
|
|
||||||
|
act(() => rerendered.root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits parent and goal context for sub-issues", async () => {
|
||||||
|
dialogState.newIssueDefaults = {
|
||||||
|
parentId: "issue-1",
|
||||||
|
parentIdentifier: "PAP-1",
|
||||||
|
parentTitle: "Parent issue",
|
||||||
|
title: "Child issue",
|
||||||
|
projectId: "project-1",
|
||||||
|
goalId: "goal-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { root } = renderDialog(container);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
||||||
|
.find((button) => button.textContent?.includes("Create Sub-Issue"));
|
||||||
|
expect(submitButton).not.toBeUndefined();
|
||||||
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
||||||
|
"company-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Child issue",
|
||||||
|
parentId: "issue-1",
|
||||||
|
goalId: "goal-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -46,6 +46,7 @@ import {
|
||||||
Paperclip,
|
Paperclip,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
ListTree,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
@ -297,6 +298,9 @@ export function NewIssueDialog() {
|
||||||
|
|
||||||
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
|
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
|
||||||
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
|
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
|
||||||
|
const isSubIssueMode = Boolean(newIssueDefaults.parentId);
|
||||||
|
const parentIssueLabel = newIssueDefaults.parentIdentifier
|
||||||
|
?? (newIssueDefaults.parentId ? newIssueDefaults.parentId.slice(0, 8) : "");
|
||||||
|
|
||||||
// Popover states
|
// Popover states
|
||||||
const [statusOpen, setStatusOpen] = useState(false);
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
|
|
@ -510,7 +514,23 @@ export function NewIssueDialog() {
|
||||||
executionWorkspaceDefaultProjectId.current = null;
|
executionWorkspaceDefaultProjectId.current = null;
|
||||||
|
|
||||||
const draft = loadDraft();
|
const draft = loadDraft();
|
||||||
if (newIssueDefaults.title) {
|
if (newIssueDefaults.parentId) {
|
||||||
|
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||||
|
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||||
|
setTitle(newIssueDefaults.title ?? "");
|
||||||
|
setDescription(newIssueDefaults.description ?? "");
|
||||||
|
setStatus(newIssueDefaults.status ?? "todo");
|
||||||
|
setPriority(newIssueDefaults.priority ?? "");
|
||||||
|
setProjectId(defaultProjectId);
|
||||||
|
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
||||||
|
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||||
|
setAssigneeModelOverride("");
|
||||||
|
setAssigneeThinkingEffort("");
|
||||||
|
setAssigneeChrome(false);
|
||||||
|
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
|
||||||
|
setSelectedExecutionWorkspaceId("");
|
||||||
|
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
|
||||||
|
} else if (newIssueDefaults.title) {
|
||||||
setTitle(newIssueDefaults.title);
|
setTitle(newIssueDefaults.title);
|
||||||
setDescription(newIssueDefaults.description ?? "");
|
setDescription(newIssueDefaults.description ?? "");
|
||||||
setStatus(newIssueDefaults.status ?? "todo");
|
setStatus(newIssueDefaults.status ?? "todo");
|
||||||
|
|
@ -616,6 +636,7 @@ export function NewIssueDialog() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCompanyChange(companyId: string) {
|
function handleCompanyChange(companyId: string) {
|
||||||
|
if (isSubIssueMode) return;
|
||||||
if (companyId === effectiveCompanyId) return;
|
if (companyId === effectiveCompanyId) return;
|
||||||
setDialogCompanyId(companyId);
|
setDialogCompanyId(companyId);
|
||||||
setAssigneeValue("");
|
setAssigneeValue("");
|
||||||
|
|
@ -666,6 +687,8 @@ export function NewIssueDialog() {
|
||||||
priority: priority || "medium",
|
priority: priority || "medium",
|
||||||
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
||||||
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
|
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
|
||||||
|
...(newIssueDefaults.parentId ? { parentId: newIssueDefaults.parentId } : {}),
|
||||||
|
...(newIssueDefaults.goalId ? { goalId: newIssueDefaults.goalId } : {}),
|
||||||
...(projectId ? { projectId } : {}),
|
...(projectId ? { projectId } : {}),
|
||||||
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
||||||
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
||||||
|
|
@ -908,6 +931,7 @@ export function NewIssueDialog() {
|
||||||
"px-1.5 py-0.5 rounded text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity",
|
"px-1.5 py-0.5 rounded text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity",
|
||||||
!dialogCompany?.brandColor && "bg-muted",
|
!dialogCompany?.brandColor && "bg-muted",
|
||||||
)}
|
)}
|
||||||
|
disabled={isSubIssueMode}
|
||||||
style={
|
style={
|
||||||
dialogCompany?.brandColor
|
dialogCompany?.brandColor
|
||||||
? {
|
? {
|
||||||
|
|
@ -955,7 +979,7 @@ export function NewIssueDialog() {
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<span className="text-muted-foreground/60">›</span>
|
<span className="text-muted-foreground/60">›</span>
|
||||||
<span>New issue</span>
|
<span>{isSubIssueMode ? "New sub-issue" : "New issue"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1119,6 +1143,19 @@ export function NewIssueDialog() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isSubIssueMode ? (
|
||||||
|
<div className="px-4 pb-2 shrink-0">
|
||||||
|
<div className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1 text-xs text-muted-foreground">
|
||||||
|
<ListTree className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="shrink-0">Sub-issue of</span>
|
||||||
|
<span className="font-medium text-foreground">{parentIssueLabel}</span>
|
||||||
|
{newIssueDefaults.parentTitle ? (
|
||||||
|
<span className="truncate">· {newIssueDefaults.parentTitle}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{currentProject && currentProjectSupportsExecutionWorkspace && (
|
{currentProject && currentProjectSupportsExecutionWorkspace && (
|
||||||
<div className="px-4 py-3 shrink-0 space-y-2">
|
<div className="px-4 py-3 shrink-0 space-y-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
@ -1455,7 +1492,7 @@ export function NewIssueDialog() {
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center justify-center gap-1.5">
|
<span className="inline-flex items-center justify-center gap-1.5">
|
||||||
{createIssue.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
{createIssue.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
<span>{createIssue.isPending ? "Creating..." : "Create Issue"}</span>
|
<span>{createIssue.isPending ? "Creating..." : isSubIssueMode ? "Create Sub-Issue" : "Create Issue"}</span>
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ interface NewIssueDefaults {
|
||||||
status?: string;
|
status?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
goalId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
parentIdentifier?: string;
|
||||||
|
parentTitle?: string;
|
||||||
assigneeAgentId?: string;
|
assigneeAgentId?: string;
|
||||||
assigneeUserId?: string;
|
assigneeUserId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
@ -11,6 +11,7 @@ import { agentsApi } from "../api/agents";
|
||||||
import { authApi } from "../api/auth";
|
import { authApi } from "../api/auth";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
|
@ -291,6 +292,7 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<st
|
||||||
export function IssueDetail() {
|
export function IssueDetail() {
|
||||||
const { issueId } = useParams<{ issueId: string }>();
|
const { issueId } = useParams<{ issueId: string }>();
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
|
const { openNewIssue } = useDialog();
|
||||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -496,6 +498,16 @@ export function IssueDetail() {
|
||||||
.filter((i) => i.parentId === issue.id)
|
.filter((i) => i.parentId === issue.id)
|
||||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||||
}, [allIssues, issue]);
|
}, [allIssues, issue]);
|
||||||
|
const openNewSubIssue = useCallback(() => {
|
||||||
|
if (!issue) return;
|
||||||
|
openNewIssue({
|
||||||
|
parentId: issue.id,
|
||||||
|
parentIdentifier: issue.identifier ?? undefined,
|
||||||
|
parentTitle: issue.title,
|
||||||
|
projectId: issue.projectId ?? undefined,
|
||||||
|
goalId: issue.goalId ?? undefined,
|
||||||
|
});
|
||||||
|
}, [issue, openNewIssue]);
|
||||||
|
|
||||||
const commentReassignOptions = useMemo(() => {
|
const commentReassignOptions = useMemo(() => {
|
||||||
const options: Array<{ id: string; label: string; searchText?: string }> = [];
|
const options: Array<{ id: string; label: string; searchText?: string }> = [];
|
||||||
|
|
@ -1042,11 +1054,16 @@ export function IssueDetail() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (issue) {
|
if (issue) {
|
||||||
openPanel(
|
openPanel(
|
||||||
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} />
|
<IssueProperties
|
||||||
|
issue={issue}
|
||||||
|
childIssues={childIssues}
|
||||||
|
onAddSubIssue={openNewSubIssue}
|
||||||
|
onUpdate={(data) => updateIssue.mutate(data)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return () => closePanel();
|
return () => closePanel();
|
||||||
}, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [childIssues, closePanel, issue, openNewSubIssue, openPanel, updateIssue]);
|
||||||
|
|
||||||
const inboxQuickArchiveArmedRef = useRef(false);
|
const inboxQuickArchiveArmedRef = useRef(false);
|
||||||
const canQuickArchiveFromInbox =
|
const canQuickArchiveFromInbox =
|
||||||
|
|
@ -1639,6 +1656,11 @@ export function IssueDetail() {
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="subissues">
|
<TabsContent value="subissues">
|
||||||
|
<div className="mb-3 flex items-center justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={openNewSubIssue}>
|
||||||
|
Add sub-issue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{childIssues.length === 0 ? (
|
{childIssues.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">No sub-issues.</p>
|
<p className="text-xs text-muted-foreground">No sub-issues.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -1779,7 +1801,13 @@ export function IssueDetail() {
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<ScrollArea className="flex-1 overflow-y-auto">
|
<ScrollArea className="flex-1 overflow-y-auto">
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} inline />
|
<IssueProperties
|
||||||
|
issue={issue}
|
||||||
|
childIssues={childIssues}
|
||||||
|
onAddSubIssue={openNewSubIssue}
|
||||||
|
onUpdate={(data) => updateIssue.mutate(data)}
|
||||||
|
inline
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue