From ab28139af3c4241dd4b0b9d7f5804583f23434ee Mon Sep 17 00:00:00 2001 From: Elliot Date: Sat, 1 Aug 2026 08:39:47 -0500 Subject: [PATCH] fix(favorites): confirm before overwriting dirty editor tab (closes #341) S-scope fix. handleClick in QueryFavorites previously overwrote a dirty query tab's content silently. Adds an isDirty guard that opens a ConfirmDialog ('Replace current tab content? Unsaved changes will be lost.') before replacing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/components/favorites/QueryFavorites.tsx | 36 ++++++++- .../__tests__/QueryFavorites.test.tsx | 80 ++++++++++++++++--- 2 files changed, 104 insertions(+), 12 deletions(-) diff --git a/src/components/favorites/QueryFavorites.tsx b/src/components/favorites/QueryFavorites.tsx index eb10e9f..170aa3f 100644 --- a/src/components/favorites/QueryFavorites.tsx +++ b/src/components/favorites/QueryFavorites.tsx @@ -14,6 +14,7 @@ import { useMemo, useState } from "react"; import { useContextMenu } from "../../hooks/useContextMenu"; import { useEditorStore } from "../../stores/editorStore"; import { type Favorite, useFavoritesStore } from "../../stores/favoritesStore"; +import { ConfirmDialog } from "../common/ConfirmDialog"; export function QueryFavorites() { const favorites = useFavoritesStore((s) => s.favorites); @@ -35,6 +36,8 @@ export function QueryFavorites() { const [editDescValue, setEditDescValue] = useState(""); const [showNewCategory, setShowNewCategory] = useState(false); const [newCategoryName, setNewCategoryName] = useState(""); + const [pendingFavorite, setPendingFavorite] = useState(null); + const [showConfirm, setShowConfirm] = useState(false); const { contextMenu, showContextMenu } = useContextMenu(); @@ -63,7 +66,7 @@ export function QueryFavorites() { return groups; }, [filtered, categories]); - const handleClick = (fav: Favorite) => { + const openFavorite = (fav: Favorite) => { const store = useEditorStore.getState(); const activeTab = store.tabs.find((t) => t.id === store.activeTabId); if (activeTab && activeTab.type === "query") { @@ -74,6 +77,30 @@ export function QueryFavorites() { } }; + const handleClick = (fav: Favorite) => { + const store = useEditorStore.getState(); + const activeTab = store.tabs.find((t) => t.id === store.activeTabId); + if (activeTab?.type === "query" && activeTab.isDirty) { + setPendingFavorite(fav); + setShowConfirm(true); + return; + } + openFavorite(fav); + }; + + const handleConfirmReplace = () => { + if (pendingFavorite) { + openFavorite(pendingFavorite); + } + setPendingFavorite(null); + setShowConfirm(false); + }; + + const handleCancelReplace = () => { + setPendingFavorite(null); + setShowConfirm(false); + }; + const handleDoubleClick = (fav: Favorite) => { const store = useEditorStore.getState(); const tabId = store.addTab(); @@ -365,6 +392,13 @@ export function QueryFavorites() { )} {contextMenu} + ); } diff --git a/src/components/favorites/__tests__/QueryFavorites.test.tsx b/src/components/favorites/__tests__/QueryFavorites.test.tsx index f5e0256..474027f 100644 --- a/src/components/favorites/__tests__/QueryFavorites.test.tsx +++ b/src/components/favorites/__tests__/QueryFavorites.test.tsx @@ -1,27 +1,32 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { QueryFavorites } from "../QueryFavorites"; -const { useFavoritesStoreFn } = vi.hoisted(() => { - return { useFavoritesStoreFn: vi.fn() }; -}); +const { useFavoritesStoreFn, updateTabContent, addTab, editorState } = vi.hoisted(() => ({ + useFavoritesStoreFn: vi.fn(), + updateTabContent: vi.fn(), + addTab: vi.fn(() => "newTabId"), + editorState: { + tabs: [{ id: "tab1", type: "query", content: "", isDirty: false }], + activeTabId: "tab1", + }, +})); -vi.mock("../../stores/favoritesStore", () => ({ +vi.mock("../../../stores/favoritesStore", () => ({ useFavoritesStore: useFavoritesStoreFn, })); -vi.mock("../../stores/editorStore", () => ({ +vi.mock("../../../stores/editorStore", () => ({ useEditorStore: { getState: vi.fn(() => ({ - tabs: [{ id: "tab1", type: "query", content: "" }], - activeTabId: "tab1", - addTab: vi.fn(() => "newTabId"), - updateTabContent: vi.fn(), + ...editorState, + addTab, + updateTabContent, })), }, })); -vi.mock("../../hooks/useContextMenu", () => ({ +vi.mock("../../../hooks/useContextMenu", () => ({ useContextMenu: vi.fn(() => ({ contextMenu: null, showContextMenu: vi.fn() })), })); @@ -60,6 +65,15 @@ beforeAll(() => { ); }); +beforeEach(() => { + updateTabContent.mockReset(); + addTab.mockReset().mockReturnValue("newTabId"); + editorState.tabs = [ + { id: "tab1", type: "query", content: "", isDirty: false }, + ]; + editorState.activeTabId = "tab1"; +}); + describe("QueryFavorites", () => { it("renders search input", () => { render(); @@ -75,4 +89,48 @@ describe("QueryFavorites", () => { const { container } = render(); expect(container.querySelector(".flex.h-full.flex-col")).toBeInTheDocument(); }); + + it("confirms before replacing a dirty query tab", () => { + editorState.tabs = [ + { + id: "tab1", + type: "query", + content: "SELECT unsaved_work", + isDirty: true, + }, + ]; + render(); + + fireEvent.click(screen.getByText("Get Active Users")); + + expect(screen.getByText("Replace current tab content?")).toBeInTheDocument(); + expect(screen.getByText("Unsaved changes will be lost.")).toBeInTheDocument(); + expect(updateTabContent).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Confirm" })); + + expect(updateTabContent).toHaveBeenCalledWith( + "tab1", + "SELECT * FROM users WHERE active = 1", + ); + expect(screen.queryByText("Unsaved changes will be lost.")).not.toBeInTheDocument(); + }); + + it("keeps dirty query content when replacement is cancelled", () => { + editorState.tabs = [ + { + id: "tab1", + type: "query", + content: "SELECT unsaved_work", + isDirty: true, + }, + ]; + render(); + + fireEvent.click(screen.getByText("Get Active Users")); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(updateTabContent).not.toHaveBeenCalled(); + expect(screen.queryByText("Unsaved changes will be lost.")).not.toBeInTheDocument(); + }); });