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:
7
.changeset/fn-6987-mission-delete-confirm.md
Normal file
7
.changeset/fn-6987-mission-delete-confirm.md
Normal 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.
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
FileText,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
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) {
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
const isActive = isInline || isOpen;
|
||||
const cacheSuffix = projectId ?? "";
|
||||
const missionsCacheKey = `${SWR_CACHE_KEYS.MISSIONS_PREFIX}${cacheSuffix}`;
|
||||
@@ -1712,11 +1714,28 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
setSelectedMission(null);
|
||||
}
|
||||
await loadMissions();
|
||||
setDeleteConfirmId(null);
|
||||
} catch (err) {
|
||||
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
|
||||
const handleCreateMilestone = useCallback(() => {
|
||||
@@ -2753,7 +2772,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
</button>
|
||||
<button
|
||||
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")}
|
||||
aria-label={t("missions.deleteMission", "Delete mission")}
|
||||
>
|
||||
@@ -4187,9 +4206,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
};
|
||||
|
||||
const shouldRenderSidebarDeleteConfirm =
|
||||
deleteConfirmId != null &&
|
||||
(deleteConfirmId.type === "interview_draft" ||
|
||||
(deleteConfirmId.type === "mission" && selectedMission?.id !== deleteConfirmId.id));
|
||||
deleteConfirmId != null && deleteConfirmId.type === "interview_draft";
|
||||
|
||||
const handleInterviewModalClose = () => {
|
||||
dismissedResumeSessionIdRef.current = effectiveResumeSessionId ?? null;
|
||||
@@ -4469,7 +4486,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
</button>
|
||||
<button
|
||||
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")}
|
||||
aria-label={t("missions.deleteMission", "Delete mission")}
|
||||
>
|
||||
@@ -4715,6 +4732,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
};
|
||||
|
||||
const renderDeleteConfirmPanel = () => {
|
||||
if (deleteConfirmId?.type === "mission") {
|
||||
return null;
|
||||
}
|
||||
const isInterviewDraftDelete = deleteConfirmId?.type === "interview_draft";
|
||||
|
||||
return (
|
||||
@@ -4730,9 +4750,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
className="mission-btn mission-btn--danger"
|
||||
onClick={async () => {
|
||||
if (!deleteConfirmId) return;
|
||||
if (deleteConfirmId.type === "mission") {
|
||||
await handleDeleteMission(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "milestone") {
|
||||
if (deleteConfirmId.type === "milestone") {
|
||||
await handleDeleteMilestone(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "slice") {
|
||||
await handleDeleteSlice(deleteConfirmId.id);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user