feat(FN-4756): complete Step 2 — add PrCreateModal component

Fusion-Task-Id: FN-4756
Fusion-Task-Lineage: 9a559f69-3ca1-4d94-9b35-9c7a0849d82c
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 02:57:14 -07:00
committed by gsxdsm
parent c2b9533a41
commit d0fb08c037
2 changed files with 641 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
.pr-create-modal {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.pr-create-modal__loading {
padding: var(--space-lg);
color: var(--text-muted);
}
.pr-create-modal__section {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.pr-create-modal__section-title,
.pr-create-modal h4 {
margin: 0;
font-size: 0.875rem;
}
.pr-create-modal__preflight {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.pr-create-modal__preflight-row {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: start;
gap: var(--space-sm);
padding: var(--space-sm);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--surface) 90%, transparent);
}
.pr-create-modal__preflight-row.is-ok {
box-shadow: var(--glow-success);
}
.pr-create-modal__preflight-row.is-failed {
box-shadow: var(--glow-danger);
}
.pr-create-modal__preflight-label {
margin: 0;
font-weight: 600;
}
.pr-create-modal__preflight-message {
margin: 0;
color: var(--text-muted);
}
.pr-create-modal__title-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-sm);
}
.pr-create-modal__label {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.pr-create-modal__inline-actions {
display: flex;
gap: var(--space-sm);
}
.pr-create-modal__body-input {
font-family: var(--font-mono);
min-height: calc(var(--space-2xl) * 4);
}
.pr-create-template-hint {
margin: 0;
color: var(--text-muted);
font-size: 0.75rem;
}
.pr-create-modal__grid-two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-lg);
}
.pr-create-modal__draft {
align-self: end;
}
.pr-create-modal__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.pr-create-modal__chip {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
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-remove {
min-width: 0;
min-height: 0;
width: auto;
height: auto;
padding: 0;
}
.pr-create-modal__option-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.pr-create-modal__option-item {
text-align: left;
}
.pr-create-collapsible {
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-sm);
}
.pr-create-collapsible summary {
cursor: pointer;
color: var(--text);
font-weight: 600;
}
.pr-create-modal__preview {
margin-top: var(--space-sm);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.pr-create-modal__commit-row,
.pr-create-modal__file-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: var(--space-sm);
align-items: center;
padding: var(--space-xs) 0;
border-bottom: var(--btn-border-width) solid color-mix(in srgb, var(--border) 70%, transparent);
}
.pr-create-modal__commit-row code {
color: var(--text-muted);
}
.pr-create-modal .form-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
}
@media (max-width: 768px) {
.pr-create-modal {
gap: var(--space-md);
}
.pr-create-modal__title-row,
.pr-create-modal__grid-two,
.pr-create-modal__commit-row,
.pr-create-modal__file-row {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.pr-create-modal__inline-actions {
width: 100%;
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,449 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { AlertTriangle, CheckCircle2, RefreshCw, Sparkles, X, XCircle } from "lucide-react";
import { getErrorMessage, type PrInfo } from "@fusion/core";
import {
createPr,
fetchPrOptions,
fetchPrPreflight,
generatePrMetadata,
type PrOptionsLabel,
type PrOptionsResponse,
type PrOptionsUser,
type PrPreflightResponse,
} from "../api";
import type { ToastType } from "../hooks/useToast";
import "./PrCreateModal.css";
interface PrCreateModalProps {
open: boolean;
taskId: string;
projectId?: string;
defaultBaseBranch?: string;
onClose: () => void;
onCreated: (prInfo: PrInfo) => void;
addToast: (message: string, type?: ToastType) => void;
}
type PreflightCheck = {
key: string;
label: string;
ok: boolean;
message: string;
warning?: boolean;
};
function OptionChips<T extends { login?: string; name?: string; color?: string }>(
{
label,
options,
selected,
onChange,
getKey,
getLabel,
includeColor,
}: {
label: string;
options: T[];
selected: T[];
onChange: (next: T[]) => void;
getKey: (option: T) => string;
getLabel: (option: T) => string;
includeColor?: boolean;
},
) {
const [query, setQuery] = useState("");
const available = useMemo(() => options.filter((opt) => !selected.some((value) => getKey(value) === getKey(opt))), [getKey, options, selected]);
const filtered = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return available;
return available.filter((opt) => getLabel(opt).toLowerCase().includes(normalized));
}, [available, getLabel, query]);
return (
<div className="pr-create-modal__section">
<label className="pr-create-modal__label">{label}</label>
<div className="pr-create-modal__chips">
{selected.map((item) => {
const key = getKey(item);
const chipStyle = includeColor && item.color
? { background: `color-mix(in srgb, #${item.color} 25%, transparent)` }
: undefined;
return (
<span key={key} className="pr-create-modal__chip" style={chipStyle}>
{getLabel(item)}
<button
type="button"
className="btn btn-icon pr-create-modal__chip-remove"
onClick={() => onChange(selected.filter((value) => getKey(value) !== key))}
aria-label={`Remove ${getLabel(item)}`}
>
<X size={14} />
</button>
</span>
);
})}
</div>
<input
className="input"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={`Filter ${label.toLowerCase()}`}
/>
{filtered.length > 0 && (
<div className="pr-create-modal__option-list">
{filtered.map((item) => (
<button
key={getKey(item)}
type="button"
className="btn btn-sm pr-create-modal__option-item"
onClick={() => {
onChange([...selected, item]);
setQuery("");
}}
>
{getLabel(item)}
</button>
))}
</div>
)}
</div>
);
}
export function PrCreateModal({
open,
taskId,
projectId,
defaultBaseBranch,
onClose,
onCreated,
addToast,
}: PrCreateModalProps) {
const headingId = useId();
const modalRef = useRef<HTMLDivElement | null>(null);
const restoreFocusRef = useRef<HTMLElement | null>(null);
const requestSeqRef = useRef(0);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [aiTitle, setAiTitle] = useState("");
const [aiBody, setAiBody] = useState("");
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [userEditedTitle, setUserEditedTitle] = useState(false);
const [userEditedBody, setUserEditedBody] = useState(false);
const [templateUsed, setTemplateUsed] = useState(false);
const [options, setOptions] = useState<PrOptionsResponse | null>(null);
const [preflight, setPreflight] = useState<PrPreflightResponse | null>(null);
const [baseBranch, setBaseBranch] = useState("");
const [draft, setDraft] = useState(false);
const [reviewers, setReviewers] = useState<PrOptionsUser[]>([]);
const [assignees, setAssignees] = useState<PrOptionsUser[]>([]);
const [labels, setLabels] = useState<PrOptionsLabel[]>([]);
const loadData = useCallback(async (baseOverride?: string) => {
const requestId = ++requestSeqRef.current;
setLoading(true);
setError(null);
try {
const [metadata, preflightData, optionsData] = await Promise.all([
generatePrMetadata(taskId, projectId),
fetchPrPreflight(taskId, projectId, baseOverride),
fetchPrOptions(taskId, projectId),
]);
if (requestId !== requestSeqRef.current) {
return;
}
setAiTitle(metadata.title);
setAiBody(metadata.body);
setTitle((current) => (current || metadata.title));
setBody((current) => (current || metadata.body));
setTemplateUsed(metadata.templateUsed);
setPreflight(preflightData);
setOptions(optionsData);
const preferredBase = baseOverride
?? defaultBaseBranch
?? preflightData.defaultBaseBranch
?? optionsData.baseBranches[0]
?? "";
setBaseBranch(preferredBase);
} catch (loadError) {
if (requestId === requestSeqRef.current) {
setError(getErrorMessage(loadError));
}
} finally {
if (requestId === requestSeqRef.current) {
setLoading(false);
}
}
}, [defaultBaseBranch, projectId, taskId]);
useEffect(() => {
if (!open) return;
void loadData();
return () => {
requestSeqRef.current += 1;
};
}, [loadData, open]);
useEffect(() => {
if (!open) return;
restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const focusable = modalRef.current?.querySelector<HTMLElement>("input, textarea, select, button, [tabindex]:not([tabindex='-1'])");
focusable?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab" || !modalRef.current) return;
const elements = Array.from(modalRef.current.querySelectorAll<HTMLElement>("button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex='-1'])"));
if (elements.length === 0) return;
const first = elements[0];
const last = elements[elements.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
restoreFocusRef.current?.focus();
};
}, [onClose, open]);
const regenerate = useCallback(async () => {
try {
const metadata = await generatePrMetadata(taskId, projectId);
setAiTitle(metadata.title);
setAiBody(metadata.body);
setTitle(metadata.title);
setBody(metadata.body);
setTemplateUsed(metadata.templateUsed);
setUserEditedTitle(false);
setUserEditedBody(false);
} catch (regenerateError) {
setError(getErrorMessage(regenerateError));
}
}, [projectId, taskId]);
const checks = useMemo<PreflightCheck[]>(() => {
if (!preflight) return [];
return [
{
key: "branch",
label: "Branch pushed to remote",
ok: preflight.branchOnRemote,
message: preflight.branchOnRemote ? "Remote branch is available." : "Push branch to remote before creating a PR.",
},
{
key: "commits",
label: "Commits available",
ok: preflight.commitsPresent,
message: preflight.commitsPresent ? "Commits are ready to submit." : "No commits found for this branch.",
},
{
key: "conflicts",
label: "No conflicts with base",
ok: !preflight.conflictsWithBase,
warning: preflight.conflictsWithBase,
message: preflight.conflictsWithBase ? "Conflicts detected. Resolve conflicts or re-run preflight." : "No merge conflicts detected.",
},
{
key: "auth",
label: "GitHub auth available",
ok: preflight.ghAuthOk,
message: preflight.ghAuthOk ? "GitHub CLI auth is available." : "Run gh auth login and try again.",
},
];
}, [preflight]);
const canSubmit = useMemo(() => checks.every((check) => check.ok), [checks]);
const handleBaseChange = useCallback(async (nextBase: string) => {
setBaseBranch(nextBase);
try {
const nextPreflight = await fetchPrPreflight(taskId, projectId, nextBase);
setPreflight(nextPreflight);
} catch (loadError) {
setError(getErrorMessage(loadError));
}
}, [projectId, taskId]);
const payload = useMemo(() => ({
title: title.trim(),
body: body.trim(),
base: baseBranch || undefined,
draft,
reviewers: reviewers.map((value) => value.login),
assignees: assignees.map((value) => value.login),
labels: labels.map((value) => value.name),
}), [assignees, baseBranch, body, draft, labels, reviewers, title]);
const submit = useCallback(async () => {
if (!payload.title || submitting) return;
setSubmitting(true);
setError(null);
try {
const prInfo = await createPr(taskId, payload, projectId);
onCreated(prInfo);
addToast(`Created PR #${prInfo.number}`, "success");
onClose();
} catch (submitError) {
setError(getErrorMessage(submitError));
} finally {
setSubmitting(false);
}
}, [addToast, onClose, onCreated, payload, projectId, submitting, taskId]);
if (!open) return null;
return (
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()}>
<div
ref={modalRef}
className="modal modal-lg pr-create-modal"
role="dialog"
aria-modal="true"
aria-labelledby={headingId}
>
<div className="modal-header">
<h2 id={headingId}>Create Pull Request</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label="Close create pull request modal">
<X size={20} />
</button>
</div>
{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) => (
<div key={check.key} className={`pr-create-modal__preflight-row ${check.ok ? "is-ok" : "is-failed"}`}>
<span className={`status-dot ${check.ok ? "status-dot--online" : check.warning ? "status-dot--pending" : "status-dot--error"}`} aria-hidden="true" />
{check.ok ? <CheckCircle2 size={16} /> : check.warning ? <AlertTriangle size={16} /> : <XCircle size={16} />}
<div>
<p className="pr-create-modal__preflight-label">{check.label}</p>
<p className="pr-create-modal__preflight-message">{check.message}</p>
</div>
</div>
))}
</div>
<button type="button" className="btn btn-sm" onClick={() => void handleBaseChange(baseBranch)}>
Re-run preflight
</button>
</section>
<section className="pr-create-modal__section">
<div className="pr-create-modal__title-row">
<label className="pr-create-modal__label" htmlFor="pr-create-modal-title">Title</label>
<div className="pr-create-modal__inline-actions">
<button type="button" className="btn btn-sm" onClick={() => void regenerate()}><Sparkles size={14} />Regenerate</button>
{userEditedTitle && <button type="button" className="btn btn-sm" onClick={() => { setTitle(aiTitle); setUserEditedTitle(false); }}>Revert to AI version</button>}
</div>
</div>
<input id="pr-create-modal-title" className="input" value={title} onChange={(event) => { setTitle(event.target.value); setUserEditedTitle(true); }} />
</section>
<section className="pr-create-modal__section">
<div className="pr-create-modal__title-row">
<label className="pr-create-modal__label" htmlFor="pr-create-modal-body">Body</label>
<div className="pr-create-modal__inline-actions">
<button type="button" className="btn btn-sm" onClick={() => void regenerate()}><Sparkles size={14} />Regenerate</button>
{userEditedBody && <button type="button" className="btn btn-sm" onClick={() => { setBody(aiBody); setUserEditedBody(false); }}>Revert to AI version</button>}
</div>
</div>
<textarea id="pr-create-modal-body" className="input pr-create-modal__body-input" value={body} onChange={(event) => { setBody(event.target.value); setUserEditedBody(true); }} rows={8} />
{templateUsed && <p className="pr-create-template-hint">Using <code>.github/pull_request_template.md</code></p>}
</section>
<section className="pr-create-modal__section pr-create-modal__grid-two">
<div>
<label className="pr-create-modal__label" htmlFor="pr-create-modal-base">Base branch</label>
<select id="pr-create-modal-base" className="select" value={baseBranch} onChange={(event) => void handleBaseChange(event.target.value)}>
{(options?.baseBranches ?? []).map((branch) => <option key={branch} value={branch}>{branch}</option>)}
</select>
</div>
<label className="checkbox-label pr-create-modal__draft">
<input type="checkbox" checked={draft} onChange={(event) => setDraft(event.target.checked)} />
Create as draft
</label>
</section>
<OptionChips
label="Reviewers"
options={options?.reviewers ?? []}
selected={reviewers}
onChange={setReviewers}
getKey={(option) => option.login}
getLabel={(option) => option.name ? `${option.name} (@${option.login})` : `@${option.login}`}
/>
<OptionChips
label="Assignees"
options={options?.assignees ?? []}
selected={assignees}
onChange={setAssignees}
getKey={(option) => option.login}
getLabel={(option) => option.name ? `${option.name} (@${option.login})` : `@${option.login}`}
/>
<OptionChips
label="Labels"
options={options?.labels ?? []}
selected={labels}
onChange={setLabels}
getKey={(option) => option.name}
getLabel={(option) => option.name}
includeColor
/>
<details className="pr-create-collapsible" open>
<summary>Diff & commit preview</summary>
<div className="pr-create-modal__preview">
<h4>Commits</h4>
{(preflight?.commits ?? []).map((commit) => (
<div className="pr-create-modal__commit-row" key={commit.sha}>
<code>{commit.sha.slice(0, 7)}</code>
<span>{commit.subject}</span>
<span>{commit.author}</span>
</div>
))}
<h4>Changed files</h4>
{(preflight?.changedFiles ?? []).map((file) => (
<div className="pr-create-modal__file-row" key={file.path}>
<span>{file.path}</span>
<span>+{file.additions} / {file.deletions}</span>
<span className={`card-status-badge card-status-badge--${file.status === "added" ? "done" : file.status === "deleted" ? "archived" : file.status === "renamed" ? "in-review" : "todo"}`}>{file.status}</span>
</div>
))}
</div>
</details>
{error && (
<div className="form-error" role="alert">
<p>{error}</p>
<button type="button" className="btn btn-sm" onClick={() => void submit()}>Retry</button>
</div>
)}
</>
)}
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose} disabled={submitting}>Cancel</button>
<button type="button" className="btn btn-primary" onClick={() => void submit()} disabled={!canSubmit || !title.trim() || submitting || loading}>
{submitting ? <RefreshCw size={14} className="spin" /> : null}
{draft ? "Create draft PR" : "Create PR"}
</button>
</div>
</div>
</div>
);
}