FN-8113: confirm GitHub issue closures
Guard GitHub issue closure with a destructive confirmation flow. - Style the Close issue action as dangerous and require confirmation before API calls. - Cover confirmation, cancellation, success, failure, and mobile behavior in modal tests. - Add a patch changeset describing the safer close interaction. Files changed: .changeset/fn-8113-github-close-confirm.md | 7 ++ .../dashboard/app/components/GitHubImportModal.tsx | 22 ++++-- .../__tests__/GitHubImportModal.test.tsx | 91 +++++++++++++++++++++- 3 files changed, 113 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-8113 Fusion-Task-Lineage: 80e4e1a3-b779-473d-9658-4231bd58d7e6 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8113-github-close-confirm.md
Normal file
7
.changeset/fn-8113-github-close-confirm.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: GitHub import "Close issue" button is now red and asks for confirmation before closing.
|
||||
category: fix
|
||||
dev: GitHubImportModal.handleCloseIssue gated behind useConfirm({ danger: true }); button uses btn-danger.
|
||||
@@ -38,6 +38,7 @@ import type { TFunction } from "i18next";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
|
||||
import { getGitHubImportState, saveGitHubImportState } from "../hooks/modalPersistence";
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
@@ -335,6 +336,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled } = useEmbeddedPresentation(presentation);
|
||||
useMobileScrollLock(isOpen && scrollLockEnabled);
|
||||
const { t, i18n } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-14-12:00:
|
||||
Translation target is the active dashboard locale (i18n.resolvedLanguage). When content is another language, the preview offers Translate / Show original / Dismiss.
|
||||
@@ -1157,17 +1159,27 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
}, []);
|
||||
|
||||
/*
|
||||
FNXC:GitHubImport 2026-07-15-17:10:
|
||||
Closing an issue keeps its FloatingWindow open through the success toast so the confirmation is visible and the locally closed state can replace the Close action. The full-width list remains behind the draggable/resizable detail window.
|
||||
FNXC:GitHubImport 2026-07-16-20:00:
|
||||
Closing permanently mutates the upstream GitHub issue, so its danger styling and confirmation gate must precede every local state mutation and API call. Keep the detail FloatingWindow open through the success toast so local closed state can replace the action.
|
||||
*/
|
||||
const handleCloseIssue = useCallback(async () => {
|
||||
if (selectedIssueNumber === null || !owner.trim() || !repo.trim()) return;
|
||||
const issueNumber = selectedIssueNumber;
|
||||
const repository = `${owner.trim()}/${repo.trim()}`;
|
||||
const shouldClose = await confirm({
|
||||
danger: true,
|
||||
title: t("git.closeIssueConfirmTitle", "Close issue #{{number}}?", { number: issueNumber }),
|
||||
message: t("git.closeIssueConfirmMessage", "This closes {{repo}}#{{number}} on GitHub. This cannot be undone from here.", { repo: repository, number: issueNumber }),
|
||||
confirmLabel: t("git.closeIssue", "Close issue"),
|
||||
cancelLabel: t("common.cancel", "Cancel"),
|
||||
});
|
||||
if (!shouldClose) return;
|
||||
|
||||
setClosingIssue(true);
|
||||
if (closeToastTimerRef.current) clearTimeout(closeToastTimerRef.current);
|
||||
setCloseToast(null);
|
||||
try {
|
||||
await apiCloseGitHubIssue(`${owner.trim()}/${repo.trim()}`, issueNumber);
|
||||
await apiCloseGitHubIssue(repository, issueNumber);
|
||||
setClosedIssueNumbers((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(issueNumber);
|
||||
@@ -1180,7 +1192,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
setClosingIssue(false);
|
||||
closeToastTimerRef.current = setTimeout(() => setCloseToast(null), 4000);
|
||||
}
|
||||
}, [selectedIssueNumber, owner, repo, t]);
|
||||
}, [selectedIssueNumber, owner, repo, t, confirm]);
|
||||
|
||||
const selectedIssue = issues.find((i) => i.number === selectedIssueNumber);
|
||||
const selectedPull = pulls.find((p) => p.number === selectedPullNumber);
|
||||
@@ -2021,7 +2033,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
<div className="github-import-detail-actions" data-testid="github-import-detail-actions">
|
||||
{activeTab === "issues" && selectedIssue && !selectedIssueClosed && (
|
||||
<button
|
||||
className="btn github-import-issue-close"
|
||||
className="btn btn-danger github-import-issue-close"
|
||||
data-testid="github-import-issue-close"
|
||||
onClick={handleCloseIssue}
|
||||
disabled={closingIssue}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import { GitHubImportModal } from "../GitHubImportModal";
|
||||
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
|
||||
import {
|
||||
apiFetchGitHubIssues,
|
||||
apiImportGitHubIssue,
|
||||
@@ -1453,9 +1454,54 @@ describe("GitHubImportModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:GitHubImport 2026-07-16-20:00:
|
||||
Closing an upstream issue is irreversible from the import view. The real provider test proves the destructive API is unreachable until confirmation and that cancellation leaves the preview unchanged.
|
||||
*/
|
||||
it("requires confirmation before closing an issue and preserves it on cancellation", async () => {
|
||||
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 });
|
||||
const issues = [
|
||||
{ number: 8, title: "Close Confirmation Issue", body: "Confirm close body", html_url: "https://github.com/owner/repo/issues/8", labels: [], state: "open" as const, author: "dave" },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
|
||||
await screen.findByText("Close Confirmation Issue");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Select issue #8/i }));
|
||||
const closeButton = await screen.findByTestId("github-import-issue-close");
|
||||
expect(closeButton).toHaveClass("btn-danger");
|
||||
|
||||
fireEvent.click(closeButton);
|
||||
const dialog = await screen.findByRole("dialog", { name: "Close issue #8?" });
|
||||
expect(dialog).toHaveTextContent("This closes dustinbyrne/kb#8 on GitHub. This cannot be undone from here.");
|
||||
expect(within(dialog).getByRole("button", { name: "Close issue" })).toHaveClass("btn-danger");
|
||||
expect(apiCloseGitHubIssue).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Close issue #8?" })).toBeNull());
|
||||
expect(apiCloseGitHubIssue).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("github-import-preview-card")).toHaveTextContent("Close Confirmation Issue");
|
||||
expect(screen.getByTestId("github-import-issue-close")).toBeTruthy();
|
||||
expect(screen.queryByTestId("github-import-issue-close-toast")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("github-import-issue-close"));
|
||||
const confirmDialog = await screen.findByRole("dialog", { name: "Close issue #8?" });
|
||||
fireEvent.click(within(confirmDialog).getByRole("button", { name: "Close issue" }));
|
||||
await waitFor(() => {
|
||||
expect(apiCloseGitHubIssue).toHaveBeenCalledTimes(1);
|
||||
expect(apiCloseGitHubIssue).toHaveBeenCalledWith("dustinbyrne/kb", 8);
|
||||
expect(screen.getByTestId("github-import-issue-close-toast")).toHaveTextContent("Issue #8 closed");
|
||||
expect(screen.queryByTestId("github-import-issue-close")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// FNXC:GitHubImport 2026-07-02-00:00: Successful Close issue returns to the issue list/no-selection state; failure stays on the preview so the user can retry.
|
||||
|
||||
|
||||
it("keeps the selected issue preview open when close fails", async () => {
|
||||
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 });
|
||||
|
||||
@@ -1466,7 +1512,11 @@ describe("GitHubImportModal", () => {
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
vi.mocked(apiCloseGitHubIssue).mockRejectedValueOnce(new Error("close failed"));
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Close Retry Issue")).toBeTruthy());
|
||||
const row = screen.getByRole("button", { name: /Select issue #8/i });
|
||||
@@ -1474,6 +1524,8 @@ describe("GitHubImportModal", () => {
|
||||
expect(await screen.findByTestId("github-import-preview-card")).toHaveTextContent("Close Retry Issue");
|
||||
|
||||
fireEvent.click(await screen.findByTestId("github-import-issue-close"));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Close issue #8?" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Close issue" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiCloseGitHubIssue).toHaveBeenCalledWith("dustinbyrne/kb", 8);
|
||||
@@ -2372,6 +2424,41 @@ describe("GitHubImportModal — compact mobile layout (operator report)", () =>
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:GitHubImport 2026-07-16-20:00:
|
||||
The compact breakpoint uses the same detail action bar, so its destructive action must retain danger styling and the cancellation gate instead of becoming a tap-through path on mobile.
|
||||
*/
|
||||
it("keeps Close issue danger-styled and confirmation-gated on mobile", async () => {
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 412 });
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
|
||||
{ number: 9, title: "Mobile Close Issue", body: "Mobile body", html_url: "https://github.com/dustinbyrne/kb/issues/9", labels: [], state: "open" },
|
||||
]);
|
||||
|
||||
try {
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
await screen.findByText("Mobile Close Issue");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Select issue #9/i }));
|
||||
const closeButton = await screen.findByTestId("github-import-issue-close");
|
||||
expect(closeButton).toHaveClass("btn-danger");
|
||||
|
||||
fireEvent.click(closeButton);
|
||||
const dialog = await screen.findByRole("dialog", { name: "Close issue #9?" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Close issue #9?" })).toBeNull());
|
||||
expect(apiCloseGitHubIssue).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("github-import-issue-close")).toBeTruthy();
|
||||
} finally {
|
||||
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: originalInnerWidth });
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
}
|
||||
});
|
||||
|
||||
it("offers Import ONLY in the detail preview, never in the list footer", async () => {
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => expect(screen.getByTestId("github-import-toolbar")).toBeTruthy());
|
||||
|
||||
Reference in New Issue
Block a user