feat(KB-639): add dismiss warning for unsaved progress in PlanningModeModal
- Add hasProgress state to track unsaved changes in PlanningModeModal\n- Show confirmation dialog when dismissing modal with unsaved progress\n- Prevent accidental data loss when users close the planning modal\n- Add comprehensive tests for dismiss warning behavior
This commit is contained in:
@@ -684,4 +684,177 @@ describe("PlanningModeModal", () => {
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dismiss warning", () => {
|
||||
it("shows confirmation when clicking X button with progress", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
|
||||
fireEvent.change(textarea, { target: { value: "Build auth system" } });
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
// Wait for question view (progress made)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
});
|
||||
|
||||
// Click X button
|
||||
const closeButton = screen.getByLabelText("Close");
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
// Should show confirmation dialog
|
||||
expect(confirmSpy).toHaveBeenCalledWith("Are you sure you want to close? Your planning progress will be lost.");
|
||||
// onClose should NOT be called since confirm returned false
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shows confirmation when clicking overlay with progress", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
|
||||
const { container } = render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
|
||||
fireEvent.change(textarea, { target: { value: "Build auth system" } });
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
// Wait for question view (progress made)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
});
|
||||
|
||||
// Click overlay (modal-overlay)
|
||||
const overlay = container.querySelector(".modal-overlay");
|
||||
expect(overlay).not.toBeNull();
|
||||
fireEvent.click(overlay!);
|
||||
|
||||
// Should show confirmation dialog
|
||||
expect(confirmSpy).toHaveBeenCalledWith("Are you sure you want to close? Your planning progress will be lost.");
|
||||
// onClose should NOT be called since confirm returned false
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("no confirmation shown when no progress made (initial state)", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
// Click X button while still in initial state (no planning started)
|
||||
const closeButton = screen.getByLabelText("Close");
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
// Should NOT show confirmation dialog
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
// onClose should be called immediately
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("closes without confirmation after confirming dismiss", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
|
||||
fireEvent.change(textarea, { target: { value: "Build auth system" } });
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
// Wait for question view (progress made)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
});
|
||||
|
||||
// Click X button
|
||||
const closeButton = screen.getByLabelText("Close");
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
// Should show confirmation dialog
|
||||
expect(confirmSpy).toHaveBeenCalledWith("Are you sure you want to close? Your planning progress will be lost.");
|
||||
|
||||
// Wait for async handleCancel to complete
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shows confirmation in summary view", async () => {
|
||||
// Override mock to return summary
|
||||
mockConnectPlanningStream.mockImplementationOnce((sessionId: string, handlers: any) => {
|
||||
setTimeout(() => {
|
||||
handlers.onSummary?.(mockSummary);
|
||||
}, 10);
|
||||
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
|
||||
fireEvent.change(textarea, { target: { value: "Build auth system" } });
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
// Wait for summary view (progress made)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Planning Complete!")).toBeDefined();
|
||||
});
|
||||
|
||||
// Click X button
|
||||
const closeButton = screen.getByLabelText("Close");
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
// Should show confirmation dialog
|
||||
expect(confirmSpy).toHaveBeenCalledWith("Are you sure you want to close? Your planning progress will be lost.");
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
|
||||
const [editedSummary, setEditedSummary] = useState<PlanningSummary | null>(null);
|
||||
const [hasProgress, setHasProgress] = useState(false);
|
||||
// Use ref instead of state for hasAutoStarted to handle React StrictMode double-render.
|
||||
// In StrictMode, components render twice but state persists across renders,
|
||||
// which would skip auto-start on the second (committed) render. Refs are
|
||||
@@ -77,6 +78,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
session: { sessionId, currentQuestion: question, summary: null },
|
||||
});
|
||||
setStreamingOutput("");
|
||||
setHasProgress(true);
|
||||
},
|
||||
onSummary: (summary) => {
|
||||
setView({
|
||||
@@ -86,6 +88,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
});
|
||||
setEditedSummary(summary);
|
||||
setStreamingOutput("");
|
||||
setHasProgress(true);
|
||||
},
|
||||
onError: (message) => {
|
||||
setError(message);
|
||||
@@ -166,7 +169,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (view.type === "question" || view.type === "summary") {
|
||||
if (hasProgress) {
|
||||
if (confirm("Are you sure you want to close? Your planning progress will be lost.")) {
|
||||
handleCancel();
|
||||
}
|
||||
@@ -178,7 +181,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, view]);
|
||||
}, [isOpen, hasProgress, handleCancel]);
|
||||
|
||||
const handleSubmitResponse = useCallback(
|
||||
async (responses: QuestionResponse) => {
|
||||
@@ -201,6 +204,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
// Submit response - AI will broadcast events via the already-connected stream
|
||||
await respondToPlanning(sessionId, responses);
|
||||
setResponseHistory((prev) => [...prev, responses]);
|
||||
setHasProgress(true);
|
||||
// Events (question/summary) will arrive via the existing SSE stream
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to submit response");
|
||||
@@ -211,6 +215,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
// Show confirmation if user has made progress
|
||||
if (hasProgress) {
|
||||
if (!confirm("Are you sure you want to close? Your planning progress will be lost.")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Always close the stream connection
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
@@ -228,9 +239,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
setResponseHistory([]);
|
||||
setEditedSummary(null);
|
||||
setStreamingOutput("");
|
||||
setHasProgress(false);
|
||||
currentSessionIdRef.current = null;
|
||||
onClose();
|
||||
}, [view, onClose]);
|
||||
}, [hasProgress, view, onClose]);
|
||||
|
||||
const handleCreateTask = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
|
||||
Reference in New Issue
Block a user