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:
committed by
gsxdsm
parent
c2b9533a41
commit
d0fb08c037
192
packages/dashboard/app/components/PrCreateModal.css
Normal file
192
packages/dashboard/app/components/PrCreateModal.css
Normal 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;
|
||||
}
|
||||
}
|
||||
449
packages/dashboard/app/components/PrCreateModal.tsx
Normal file
449
packages/dashboard/app/components/PrCreateModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user