feat(FN-5049): complete Step 1 — add PR create modal scroll body

Fusion-Task-Id: FN-5049
Fusion-Task-Lineage: d4b3872d-00ff-466a-bb0d-893c5d90962c
This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 07:35:14 -07:00
committed by gsxdsm
parent be6279f656
commit b4d6b85692
3 changed files with 143 additions and 18 deletions

View File

@@ -1,7 +1,16 @@
.pr-create-modal {
display: flex;
flex-direction: column;
}
.pr-create-modal__body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--space-lg);
min-height: 0;
overflow-y: auto;
padding: var(--space-lg) var(--modal-padding);
}
.pr-create-modal__loading {
@@ -100,18 +109,25 @@
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
min-width: 0;
}
.pr-create-modal__chip {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-width: 0;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-pill);
border: var(--btn-border-width) solid var(--border);
background: color-mix(in srgb, var(--card) 88%, transparent);
}
.pr-create-modal__chip-label {
min-width: 0;
overflow-wrap: anywhere;
}
.pr-create-modal__chip--colored {
background: color-mix(in srgb, var(--pr-chip-label-color, var(--card)) 25%, transparent);
}
@@ -128,6 +144,7 @@
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
min-width: 0;
}
.pr-create-modal__option-item {
@@ -167,6 +184,12 @@
color: var(--text-muted);
}
.pr-create-modal__commit-row > *,
.pr-create-modal__file-row > * {
min-width: 0;
overflow-wrap: anywhere;
}
.pr-create-modal .form-error {
display: flex;
align-items: center;
@@ -198,7 +221,7 @@
}
@media (max-width: 768px) {
.pr-create-modal {
.pr-create-modal__body {
gap: var(--space-md);
}
@@ -211,6 +234,28 @@
align-items: flex-start;
}
.pr-create-modal__draft {
align-self: start;
}
.pr-create-modal__preflight-row {
grid-template-columns: auto 1fr;
}
.pr-create-modal__preflight-row .status-dot {
grid-column: 1;
grid-row: 1;
}
.pr-create-modal__preflight-row svg {
grid-column: 2;
grid-row: 1;
}
.pr-create-modal__preflight-row > div {
grid-column: 1 / -1;
}
.pr-create-modal__inline-actions {
width: 100%;
flex-wrap: wrap;

View File

@@ -77,7 +77,7 @@ function OptionChips<T extends { login?: string; name?: string; color?: string }
className={`pr-create-modal__chip${hasColor ? " pr-create-modal__chip--colored" : ""}`}
style={chipStyle}
>
{getLabel(item)}
<span className="pr-create-modal__chip-label">{getLabel(item)}</span>
<button
type="button"
className="btn btn-icon pr-create-modal__chip-remove"
@@ -336,9 +336,10 @@ export function PrCreateModal({
</button>
</div>
{loading ? <div className="pr-create-modal__loading">Loading PR metadata</div> : (
<>
<section className="pr-create-modal__section">
<div className="pr-create-modal__body">
{loading ? <div className="pr-create-modal__loading">Loading PR metadata</div> : (
<>
<section className="pr-create-modal__section">
<h3 className="pr-create-modal__section-title">Pre-flight checks</h3>
<div className="pr-create-modal__preflight">
{checks.map((check) => (
@@ -441,20 +442,21 @@ export function PrCreateModal({
</div>
</details>
{error && (
<div className="form-error pr-error" role="alert">
<p>{error}</p>
{lastGhError?.hint ? <p className="pr-error__hint">{lastGhError.hint}</p> : null}
<div className="pr-error__actions">
{lastGhError?.action?.kind === "shell" ? <p>Action: run <code>{lastGhError.action.command}</code></p> : null}
{lastGhError?.action?.kind === "open" ? <p>Action: open <a href={lastGhError.action.url} target="_blank" rel="noreferrer">docs</a></p> : null}
{lastGhError?.retryable ? <button type="button" className="btn btn-sm pr-error__retry" onClick={() => void submit()}>Retry</button> : null}
<button type="button" className="btn btn-sm pr-error__dismiss" onClick={() => { setLastGhError(null); setError(null); }} aria-label="Dismiss PR error">×</button>
{error && (
<div className="form-error pr-error" role="alert">
<p>{error}</p>
{lastGhError?.hint ? <p className="pr-error__hint">{lastGhError.hint}</p> : null}
<div className="pr-error__actions">
{lastGhError?.action?.kind === "shell" ? <p>Action: run <code>{lastGhError.action.command}</code></p> : null}
{lastGhError?.action?.kind === "open" ? <p>Action: open <a href={lastGhError.action.url} target="_blank" rel="noreferrer">docs</a></p> : null}
{lastGhError?.retryable ? <button type="button" className="btn btn-sm pr-error__retry" onClick={() => void submit()}>Retry</button> : null}
<button type="button" className="btn btn-sm pr-error__dismiss" onClick={() => { setLastGhError(null); setError(null); }} aria-label="Dismiss PR error">×</button>
</div>
</div>
</div>
)}
</>
)}
)}
</>
)}
</div>
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose} disabled={submitting}>Cancel</button>

View File

@@ -0,0 +1,78 @@
import { render, screen, waitFor } from "@testing-library/react";
import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { PrCreateModal } from "../PrCreateModal";
import { loadAllAppCss } from "../../test/cssFixture";
const mocks = vi.hoisted(() => ({
generatePrMetadata: vi.fn(),
fetchPrPreflight: vi.fn(),
fetchPrOptions: vi.fn(),
createPr: vi.fn(),
}));
vi.mock("../../api", () => ({
generatePrMetadata: mocks.generatePrMetadata,
fetchPrPreflight: mocks.fetchPrPreflight,
fetchPrOptions: mocks.fetchPrOptions,
createPr: mocks.createPr,
}));
describe("PrCreateModal layout", () => {
let styleEl: HTMLStyleElement;
beforeAll(() => {
styleEl = document.createElement("style");
styleEl.textContent = loadAllAppCss();
document.head.appendChild(styleEl);
});
afterAll(() => {
styleEl.remove();
});
beforeEach(() => {
vi.clearAllMocks();
mocks.generatePrMetadata.mockResolvedValue({ title: "AI title", body: "AI body", templateUsed: false });
mocks.fetchPrPreflight.mockResolvedValue({
branchOnRemote: true,
commitsPresent: true,
conflictsWithBase: false,
ghAuthOk: true,
defaultBaseBranch: "main",
head: "fusion/FN-5049",
commits: [],
changedFiles: [],
});
mocks.fetchPrOptions.mockResolvedValue({
baseBranches: ["main"],
reviewers: [],
assignees: [],
labels: [],
});
});
it("renders a dedicated scroll body between modal header and actions", async () => {
render(<PrCreateModal open taskId="FN-5049" onClose={vi.fn()} onCreated={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(mocks.generatePrMetadata).toHaveBeenCalled());
const dialog = screen.getByRole("dialog");
const modal = dialog.classList.contains("modal") ? dialog : dialog.closest(".modal");
expect(modal).toBeTruthy();
const header = modal?.querySelector(":scope > .modal-header");
const body = modal?.querySelector(":scope > .pr-create-modal__body");
const actions = modal?.querySelector(":scope > .modal-actions");
expect(header).toBeTruthy();
expect(body).toBeTruthy();
expect(actions).toBeTruthy();
expect(actions?.parentElement).toBe(modal);
const allAutoOverflow = Array.from(modal?.querySelectorAll<HTMLElement>("*") ?? []).filter(
(element) => getComputedStyle(element).overflowY === "auto",
);
expect(allAutoOverflow).toHaveLength(1);
expect(allAutoOverflow[0]).toBe(body);
});
});