feat(FN-5018): wire create-pr review surface in TaskReviewTab
Wires the PR-create review surface into the task review flow, adding the UI surface to `TaskReviewTab`, `TaskDetailModal`, and `PrPanel` for reviewing PR creation as part of task review. Includes tests covering the create-PR review flow in both `TaskDetailModal` and `TaskReviewTab`. Fusion-Task-Id: FN-5018
This commit is contained in:
committed by
gsxdsm
parent
0607ca518f
commit
5a1794fc55
7
.changeset/fn-5018-pr-create-review-surface.md
Normal file
7
.changeset/fn-5018-pr-create-review-surface.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Wire the redesigned Create-PR modal into the task detail modal and the merge/review tab so
|
||||
PrPanel's Create button and a new review-tab Create-PR action open the same flow. Fixes the
|
||||
FN-4758 follow-up where PrPanel was mounted with `onRequestCreatePr={undefined}`.
|
||||
@@ -164,6 +164,7 @@ export function PrPanel({
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={onRequestCreatePr}
|
||||
disabled={createDisabled}
|
||||
data-testid="pr-panel-create-pr"
|
||||
title={prAuthAvailable ? "Create a PR for this task" : "PR auth unavailable — run 'gh auth login'"}
|
||||
>
|
||||
<Plus />
|
||||
|
||||
@@ -2534,7 +2534,14 @@ export function TaskDetailContent({
|
||||
) : activeTab === "changes" ? (
|
||||
<TaskChangesTab taskId={task.id} worktree={task.worktree} projectId={projectId} column={task.column} mergeDetails={task.mergeDetails} modifiedFiles={task.modifiedFiles} />
|
||||
) : activeTab === "review" ? (
|
||||
<TaskReviewTab task={task} addToast={addToast} projectId={projectId} onTaskUpdated={onTaskUpdated} />
|
||||
<TaskReviewTab
|
||||
task={task}
|
||||
addToast={addToast}
|
||||
projectId={projectId}
|
||||
onTaskUpdated={onTaskUpdated}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
onRequestCreatePr={() => setPrCreateOpen(true)}
|
||||
/>
|
||||
) : activeTab === "comments" ? (
|
||||
<TaskComments task={task} addToast={addToast} projectId={projectId} onTaskUpdated={onTaskUpdated} />
|
||||
) : activeTab === "documents" ? (
|
||||
@@ -3332,9 +3339,11 @@ export function TaskDetailContent({
|
||||
open={prCreateOpen}
|
||||
taskId={task.id}
|
||||
projectId={projectId}
|
||||
defaultBaseBranch={undefined}
|
||||
onClose={() => setPrCreateOpen(false)}
|
||||
onCreated={(prInfo) => {
|
||||
(task as TaskDetail).prInfo = prInfo;
|
||||
onTaskUpdated?.({ ...workingTask, prInfo } as Task);
|
||||
setPrCreateOpen(false);
|
||||
}}
|
||||
addToast={addToast}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./TaskReviewTab.css";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import { GitPullRequest } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -13,6 +14,8 @@ interface Props {
|
||||
task: Task | TaskDetail;
|
||||
projectId?: string;
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
onRequestCreatePr?: () => void;
|
||||
prAuthAvailable?: boolean;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
@@ -129,7 +132,14 @@ function getDisplayReviewItems(review: ReviewState): DisplayReviewItem[] {
|
||||
return [...items, ...snapshots];
|
||||
}
|
||||
|
||||
export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Props) {
|
||||
export function TaskReviewTab({
|
||||
task,
|
||||
projectId,
|
||||
onTaskUpdated,
|
||||
onRequestCreatePr,
|
||||
prAuthAvailable,
|
||||
addToast,
|
||||
}: Props) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [revising, setRevising] = useState(false);
|
||||
@@ -275,6 +285,12 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
|
||||
{decisionLabel ? <span className={`task-review-tab__decision task-review-tab__decision--${decisionLabel}`}>{decisionLabel}</span> : null}
|
||||
</div>
|
||||
<div className="task-review-tab__actions">
|
||||
{task.column === "in-review" && !task.prInfo && prAuthAvailable === true && typeof onRequestCreatePr === "function" ? (
|
||||
<button className="btn btn-sm" onClick={() => onRequestCreatePr?.()} data-testid="task-review-create-pr">
|
||||
<GitPullRequest />
|
||||
Create PR
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => setRenderMarkdown((prev) => !prev)}
|
||||
|
||||
@@ -10,6 +10,10 @@ const prCreateModalState = vi.hoisted(() => ({
|
||||
latestProps: null as any,
|
||||
}));
|
||||
|
||||
const taskReviewTabState = vi.hoisted(() => ({
|
||||
latestProps: null as any,
|
||||
}));
|
||||
|
||||
vi.mock("../PrPanel", () => ({
|
||||
PrPanel: (props: any) => {
|
||||
prPanelState.latestPrInfo = props.prInfo;
|
||||
@@ -48,11 +52,25 @@ vi.mock("../PrCreateModal", () => ({
|
||||
>
|
||||
Stub create
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onClose()}>
|
||||
Stub close
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../TaskReviewTab", () => ({
|
||||
TaskReviewTab: (props: any) => {
|
||||
taskReviewTabState.latestProps = props;
|
||||
return (
|
||||
<button type="button" data-testid="task-review-create-pr" onClick={() => props.onRequestCreatePr?.()}>
|
||||
Review create PR
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
makeTask,
|
||||
noop,
|
||||
@@ -71,10 +89,12 @@ describe("TaskDetailModal create-PR wiring", () => {
|
||||
vi.clearAllMocks();
|
||||
prPanelState.latestPrInfo = undefined;
|
||||
prCreateModalState.latestProps = null;
|
||||
taskReviewTabState.latestProps = null;
|
||||
});
|
||||
|
||||
it("opens PrCreateModal from PrPanel and updates prInfo on create", async () => {
|
||||
const addToast = vi.fn();
|
||||
const onTaskUpdated = vi.fn();
|
||||
const task = makeTask({ id: "FN-5020", prInfo: undefined, column: "in-review" });
|
||||
|
||||
render(
|
||||
@@ -87,6 +107,7 @@ describe("TaskDetailModal create-PR wiring", () => {
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={addToast}
|
||||
onTaskUpdated={onTaskUpdated}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -101,6 +122,7 @@ describe("TaskDetailModal create-PR wiring", () => {
|
||||
expect(prCreateModalState.latestProps?.open).toBe(true);
|
||||
expect(prCreateModalState.latestProps?.taskId).toBe("FN-5020");
|
||||
expect(prCreateModalState.latestProps?.projectId).toBe("project-123");
|
||||
expect(prCreateModalState.latestProps?.defaultBaseBranch).toBeUndefined();
|
||||
expect(prCreateModalState.latestProps?.addToast).toBe(addToast);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stub create" }));
|
||||
@@ -109,5 +131,41 @@ describe("TaskDetailModal create-PR wiring", () => {
|
||||
expect(prCreateModalState.latestProps?.open).toBe(false);
|
||||
expect(screen.getByTestId("pr-panel-pr-number")).toHaveTextContent("321");
|
||||
expect(prPanelState.latestPrInfo?.number).toBe(321);
|
||||
expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: "FN-5020",
|
||||
prInfo: expect.objectContaining({ number: 321 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("opens the same PrCreateModal from TaskReviewTab and closes via onClose", async () => {
|
||||
const task = makeTask({ id: "FN-5021", prInfo: undefined, column: "in-review" });
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={task}
|
||||
projectId="project-123"
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={vi.fn()}
|
||||
prAuthAvailable
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review" }));
|
||||
await waitFor(() => expect(screen.getByTestId("task-review-create-pr")).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByTestId("task-review-create-pr"));
|
||||
expect(screen.getByTestId("pr-create-modal-stub")).toBeInTheDocument();
|
||||
expect(prCreateModalState.latestProps?.taskId).toBe("FN-5021");
|
||||
expect(prCreateModalState.latestProps?.projectId).toBe("project-123");
|
||||
expect(prCreateModalState.latestProps?.defaultBaseBranch).toBeUndefined();
|
||||
expect(taskReviewTabState.latestProps?.prAuthAvailable).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stub close" }));
|
||||
expect(screen.queryByTestId("pr-create-modal-stub")).toBeNull();
|
||||
expect(prCreateModalState.latestProps?.open).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -493,6 +493,64 @@ describe("TaskReviewTab", () => {
|
||||
expect(mobileCss).not.toMatch(/\.task-review-tab__actions\s+\.btn\s*\{[^}]*flex\s*:\s*1\s*;[^}]*\}/);
|
||||
});
|
||||
|
||||
it("shows create PR action when in-review without prInfo and auth is available", async () => {
|
||||
const onRequestCreatePr = vi.fn();
|
||||
const task = makeTask({ column: "in-review", prInfo: undefined });
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
|
||||
render(
|
||||
<TaskReviewTab
|
||||
task={task}
|
||||
addToast={vi.fn()}
|
||||
prAuthAvailable
|
||||
onRequestCreatePr={onRequestCreatePr}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("task-review-create-pr"));
|
||||
expect(onRequestCreatePr).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides create PR action outside in-review column", async () => {
|
||||
const task = makeTask({ column: "todo", prInfo: undefined });
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
|
||||
render(<TaskReviewTab task={task} addToast={vi.fn()} prAuthAvailable onRequestCreatePr={vi.fn()} />);
|
||||
|
||||
await screen.findByRole("button", { name: "Refresh" });
|
||||
expect(screen.queryByTestId("task-review-create-pr")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides create PR action when prInfo already exists", async () => {
|
||||
const task = makeTask({
|
||||
column: "in-review",
|
||||
prInfo: {
|
||||
number: 1,
|
||||
title: "Existing PR",
|
||||
url: "https://example.com/pr/1",
|
||||
status: "open",
|
||||
headBranch: "fusion/FN-1",
|
||||
baseBranch: "main",
|
||||
},
|
||||
});
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
|
||||
render(<TaskReviewTab task={task} addToast={vi.fn()} prAuthAvailable onRequestCreatePr={vi.fn()} />);
|
||||
|
||||
await screen.findByRole("button", { name: "Refresh" });
|
||||
expect(screen.queryByTestId("task-review-create-pr")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides create PR action when auth is unavailable", async () => {
|
||||
const task = makeTask({ column: "in-review", prInfo: undefined });
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
|
||||
render(<TaskReviewTab task={task} addToast={vi.fn()} prAuthAvailable={false} onRequestCreatePr={vi.fn()} />);
|
||||
|
||||
await screen.findByRole("button", { name: "Refresh" });
|
||||
expect(screen.queryByTestId("task-review-create-pr")).toBeNull();
|
||||
});
|
||||
|
||||
it("submits reviewer-agent selections through same revision action", async () => {
|
||||
const task = makeTask();
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({
|
||||
|
||||
Reference in New Issue
Block a user