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,
|
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);
|
||||||
|
|||||||
@@ -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