FN-6987: load mission delete confirmation in modal

Mission delete prompts now open through the shared confirmation modal instead of inline mission panels.

- Route mission list and detail delete buttons through ConfirmDialogProvider.
- Keep inline delete panels reserved for non-mission destructive actions.
- Add desktop, mobile, cancel, success, and failure regression coverage.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/fn-6987-mission-delete-confirm.md       |   7 +
 .../dashboard/app/components/MissionManager.tsx    |  38 +++-
 .../MissionManager.delete-confirm.test.tsx         | 243 +++++++++++++++++++++
 3 files changed, 278 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-6987
Fusion-Task-Lineage: df8a1a05-ff95-4d24-851d-053ecb017353
This commit is contained in:
gsxdsm
2026-06-24 23:37:58 -07:00
parent ad9b7b9ce3
commit 7a00811b4a
3 changed files with 278 additions and 10 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Open Mission Manager mission-delete confirmations in the standard modal dialog.
category: fix
dev: Routes mission list and detail delete affordances through ConfirmDialogProvider with regression coverage.

View File

@@ -28,6 +28,7 @@ import {
FileText, FileText,
RefreshCw, RefreshCw,
} from "lucide-react"; } from "lucide-react";
import { useConfirm } from "../hooks/useConfirm";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { useViewportMode } from "../hooks/useViewportMode"; import { useViewportMode } from "../hooks/useViewportMode";
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory"; import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
@@ -614,6 +615,7 @@ function normalizeMissionHierarchy(mission: MissionWithHierarchy): MissionWithHi
export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError, onNavigateToGoal }: MissionManagerProps) { export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError, onNavigateToGoal }: MissionManagerProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { confirm } = useConfirm();
const isActive = isInline || isOpen; const isActive = isInline || isOpen;
const cacheSuffix = projectId ?? ""; const cacheSuffix = projectId ?? "";
const missionsCacheKey = `${SWR_CACHE_KEYS.MISSIONS_PREFIX}${cacheSuffix}`; const missionsCacheKey = `${SWR_CACHE_KEYS.MISSIONS_PREFIX}${cacheSuffix}`;
@@ -1712,11 +1714,28 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
setSelectedMission(null); setSelectedMission(null);
} }
await loadMissions(); await loadMissions();
setDeleteConfirmId(null);
} catch (err) { } catch (err) {
addToast(getErrorMessage(err) || t("missions.deleteFailed", "Failed to delete mission"), "error"); addToast(getErrorMessage(err) || t("missions.deleteFailed", "Failed to delete mission"), "error");
} }
}, [addToast, loadMissions, selectedMission, projectId]); }, [addToast, loadMissions, selectedMission, projectId, t]);
const requestDeleteMission = useCallback(async (missionId: string) => {
/*
FNXC:MissionManager 2026-06-24-00:00:
Mission deletes are destructive and must use the app-level modal confirmation from both list and detail delete affordances. Keeping this path out of the inline mission-confirm-panel prevents the prompt from landing in the sidebar footer, detail pane, or below mobile scroll content.
*/
const confirmed = await confirm({
title: t("missions.deleteMission", "Delete mission"),
message: t("missions.deleteConfirm", "Delete this {{type}}? This cannot be undone.", { type: "mission" }),
confirmLabel: t("missions.deleteButton", "Delete"),
cancelLabel: t("missions.cancelButton", "Cancel"),
danger: true,
});
if (!confirmed) {
return;
}
await handleDeleteMission(missionId);
}, [confirm, handleDeleteMission, t]);
// Milestone handlers // Milestone handlers
const handleCreateMilestone = useCallback(() => { const handleCreateMilestone = useCallback(() => {
@@ -2753,7 +2772,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
</button> </button>
<button <button
className="mission-icon-btn mission-icon-btn--danger" className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "mission", id: selectedMission.id })} onClick={() => void requestDeleteMission(selectedMission.id)}
title={t("missions.deleteMission", "Delete mission")} title={t("missions.deleteMission", "Delete mission")}
aria-label={t("missions.deleteMission", "Delete mission")} aria-label={t("missions.deleteMission", "Delete mission")}
> >
@@ -4187,9 +4206,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}; };
const shouldRenderSidebarDeleteConfirm = const shouldRenderSidebarDeleteConfirm =
deleteConfirmId != null && deleteConfirmId != null && deleteConfirmId.type === "interview_draft";
(deleteConfirmId.type === "interview_draft" ||
(deleteConfirmId.type === "mission" && selectedMission?.id !== deleteConfirmId.id));
const handleInterviewModalClose = () => { const handleInterviewModalClose = () => {
dismissedResumeSessionIdRef.current = effectiveResumeSessionId ?? null; dismissedResumeSessionIdRef.current = effectiveResumeSessionId ?? null;
@@ -4469,7 +4486,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
</button> </button>
<button <button
className="mission-icon-btn mission-icon-btn--danger" className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "mission", id: m.id })} onClick={() => void requestDeleteMission(m.id)}
title={t("missions.deleteMission", "Delete mission")} title={t("missions.deleteMission", "Delete mission")}
aria-label={t("missions.deleteMission", "Delete mission")} aria-label={t("missions.deleteMission", "Delete mission")}
> >
@@ -4715,6 +4732,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}; };
const renderDeleteConfirmPanel = () => { const renderDeleteConfirmPanel = () => {
if (deleteConfirmId?.type === "mission") {
return null;
}
const isInterviewDraftDelete = deleteConfirmId?.type === "interview_draft"; const isInterviewDraftDelete = deleteConfirmId?.type === "interview_draft";
return ( return (
@@ -4730,9 +4750,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
className="mission-btn mission-btn--danger" className="mission-btn mission-btn--danger"
onClick={async () => { onClick={async () => {
if (!deleteConfirmId) return; if (!deleteConfirmId) return;
if (deleteConfirmId.type === "mission") { if (deleteConfirmId.type === "milestone") {
await handleDeleteMission(deleteConfirmId.id);
} else if (deleteConfirmId.type === "milestone") {
await handleDeleteMilestone(deleteConfirmId.id); await handleDeleteMilestone(deleteConfirmId.id);
} else if (deleteConfirmId.type === "slice") { } else if (deleteConfirmId.type === "slice") {
await handleDeleteSlice(deleteConfirmId.id); await handleDeleteSlice(deleteConfirmId.id);

View File

@@ -0,0 +1,243 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MissionManager } from "../MissionManager";
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
const mockViewportMode = vi.fn<() => "mobile" | "desktop">();
const mockFetchMissions = vi.fn();
const mockFetchMission = vi.fn();
const mockFetchMissionsHealth = vi.fn();
const mockFetchMissionEvents = vi.fn();
const mockFetchAssertions = vi.fn();
const mockFetchMilestoneValidation = vi.fn();
const mockFetchMilestoneValidationTelemetry = vi.fn();
const mockFetchValidationLoopState = vi.fn();
const mockFetchValidationRuns = vi.fn();
const mockFetchAiSessions = vi.fn();
const mockFetchAiSession = vi.fn();
const mockFetchMissionInterviewDrafts = vi.fn();
const mockDeleteMission = vi.fn();
const mockSubscribeSse = vi.fn(() => vi.fn());
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
getViewportMode: () => mockViewportMode(),
isMobileViewport: () => mockViewportMode() === "mobile",
useViewportMode: () => mockViewportMode(),
}));
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
vi.mock("../../sse-bus", () => ({
subscribeSse: (...args: unknown[]) => mockSubscribeSse(...args),
}));
vi.mock("../MissionInterviewModal", () => ({
MissionInterviewModal: () => null,
}));
vi.mock("../MilestoneSliceInterviewModal", () => ({
MilestoneSliceInterviewModal: () => null,
}));
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
fetchMissions: (...args: unknown[]) => mockFetchMissions(...args),
fetchMission: (...args: unknown[]) => mockFetchMission(...args),
fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args),
fetchMissionEvents: (...args: unknown[]) => mockFetchMissionEvents(...args),
fetchAssertions: (...args: unknown[]) => mockFetchAssertions(...args),
fetchMilestoneValidation: (...args: unknown[]) => mockFetchMilestoneValidation(...args),
fetchMilestoneValidationTelemetry: (...args: unknown[]) => mockFetchMilestoneValidationTelemetry(...args),
fetchValidationLoopState: (...args: unknown[]) => mockFetchValidationLoopState(...args),
fetchValidationRuns: (...args: unknown[]) => mockFetchValidationRuns(...args),
fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args),
fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args),
deleteMission: (...args: unknown[]) => mockDeleteMission(...args),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
};
});
const projectId = "project-1";
const missions = [
{
id: "M-001",
title: "Build Auth System",
description: "Complete authentication flow",
status: "planning",
interviewState: "not_started",
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "M-002",
title: "API Redesign",
description: "Redesign the REST API",
status: "active",
interviewState: "not_started",
milestones: [],
createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
},
];
const missionDetails = new Map([
[
"M-001",
{
id: "M-001",
title: "Build Auth System",
description: "Complete authentication flow",
status: "planning",
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
],
[
"M-002",
{
id: "M-002",
title: "API Redesign",
description: "Redesign the REST API",
status: "active",
milestones: [],
createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
},
],
]);
function setupMocks() {
mockFetchMissions.mockResolvedValue(missions);
mockFetchMission.mockImplementation(async (missionId: string) => missionDetails.get(missionId));
mockFetchMissionsHealth.mockResolvedValue({});
mockFetchMissionEvents.mockResolvedValue([]);
mockFetchAssertions.mockResolvedValue([]);
mockFetchMilestoneValidation.mockResolvedValue(null);
mockFetchMilestoneValidationTelemetry.mockResolvedValue(null);
mockFetchValidationLoopState.mockResolvedValue(null);
mockFetchValidationRuns.mockResolvedValue([]);
mockFetchAiSessions.mockResolvedValue([]);
mockFetchAiSession.mockResolvedValue(null);
mockFetchMissionInterviewDrafts.mockResolvedValue([]);
mockDeleteMission.mockResolvedValue(undefined);
}
function renderMissionManager(addToast = vi.fn()) {
const result = render(
<ConfirmDialogProvider>
<MissionManager isInline isOpen onClose={() => {}} addToast={addToast} projectId={projectId} />
</ConfirmDialogProvider>,
);
return { ...result, addToast };
}
async function findMissionListItem(title: string): Promise<HTMLElement> {
const titleNode = await screen.findByText(title);
const item = titleNode.closest(".mission-list__item");
expect(item).not.toBeNull();
return item as HTMLElement;
}
async function openListDeleteDialog(title: string, container: HTMLElement) {
const item = await findMissionListItem(title);
fireEvent.click(within(item).getByRole("button", { name: "Delete mission" }));
const dialog = await screen.findByRole("dialog", { name: "Delete mission" });
expect(dialog).toHaveTextContent("Delete this mission? This cannot be undone.");
expect(container.querySelector(".mission-confirm-panel")).toBeNull();
return dialog;
}
describe("MissionManager mission delete confirmation", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockViewportMode.mockReturnValue("desktop");
setupMocks();
});
it("opens a modal for desktop list deletes and cancel leaves the mission untouched", async () => {
const { container } = renderMissionManager();
const dialog = await openListDeleteDialog("API Redesign", container);
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => {
expect(screen.queryByRole("dialog", { name: "Delete mission" })).not.toBeInTheDocument();
});
expect(mockDeleteMission).not.toHaveBeenCalled();
expect(screen.getByText("API Redesign")).toBeInTheDocument();
});
it("confirms desktop selected-detail deletes through the modal and refreshes missions", async () => {
const addToast = vi.fn();
renderMissionManager(addToast);
await waitFor(() => {
expect(mockFetchMission).toHaveBeenCalledWith("M-001", projectId);
});
fireEvent.click(screen.getAllByRole("button", { name: "Delete mission" })[0]);
const dialog = await screen.findByRole("dialog", { name: "Delete mission" });
expect(document.querySelector(".mission-confirm-panel")).toBeNull();
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => {
expect(mockDeleteMission).toHaveBeenCalledWith("M-001", projectId);
});
expect(addToast).toHaveBeenCalledWith("Mission deleted", "success");
expect(mockFetchMissions).toHaveBeenCalledTimes(2);
});
it("confirms mobile stacked detail deletes through the modal", async () => {
mockViewportMode.mockReturnValue("mobile");
renderMissionManager();
const item = await findMissionListItem("Build Auth System");
fireEvent.click(item);
await waitFor(() => {
expect(mockFetchMission).toHaveBeenCalledWith("M-001", projectId);
});
await waitFor(() => {
expect(document.querySelector('button[title="Delete mission"]')).toBeInTheDocument();
});
fireEvent.click(document.querySelector('button[title="Delete mission"]') as HTMLButtonElement);
const dialog = await screen.findByRole("dialog", { name: "Delete mission" });
expect(document.querySelector(".mission-confirm-panel")).toBeNull();
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => {
expect(mockDeleteMission).toHaveBeenCalledWith("M-001", projectId);
});
});
it("keeps the mission visible and emits an error toast when modal-confirmed delete fails", async () => {
const addToast = vi.fn();
mockDeleteMission.mockRejectedValueOnce(new Error("delete failed upstream"));
const { container } = renderMissionManager(addToast);
const dialog = await openListDeleteDialog("API Redesign", container);
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => {
expect(mockDeleteMission).toHaveBeenCalledWith("M-002", projectId);
});
expect(screen.getByText("API Redesign")).toBeInTheDocument();
expect(addToast).toHaveBeenCalledWith("delete failed upstream", "error");
});
});