From 5ab7834b227a8a1c724b9eb716ffa6d5d614e0be Mon Sep 17 00:00:00 2001 From: Anoyou <13214236+Anoyou@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:29:59 +0800 Subject: [PATCH] fix(gui): prevent accidental Add Provider modal dismissal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the modal-overlay backdrop closed the Add Provider modal and unmounted it, discarding everything the user had already typed (provider name, base URL, API key). The same teardown fired when a text selection drag started inside an input was released outside the card. Remove the overlay onClick so the backdrop no longer dismisses the modal. The explicit close paths are unchanged: the × button, Escape, and the existing close-on-successful-add. Add a regression test that mounts the modal, types into the form, clicks the backdrop, simulates a selection drag released on the backdrop, and asserts the modal stays open with its input intact; the × button and Escape still close it. --- gui/src/components/AddProviderModal.tsx | 8 +- .../add-provider-modal-backdrop.test.tsx | 155 ++++++++++++++++++ 2 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 gui/tests/add-provider-modal-backdrop.test.tsx diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index 21a7e702f0..979b680976 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -229,8 +229,12 @@ export default function AddProviderModal({ return ( <> -
-
e.stopPropagation()}> + {/* The backdrop must not dismiss this modal: a stray click — or a text + selection drag that is released outside the card — would wipe every + field the user already filled in. Close only via the × button, + Escape, or a successful add. */} +
+

{preset ? t("modal.addNamed", { label: preset.label }) : t("modal.add")}

diff --git a/gui/tests/add-provider-modal-backdrop.test.tsx b/gui/tests/add-provider-modal-backdrop.test.tsx new file mode 100644 index 0000000000..df86acf2f0 --- /dev/null +++ b/gui/tests/add-provider-modal-backdrop.test.tsx @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import AddProviderModal from "../src/components/AddProviderModal"; + +/** + * The Add Provider modal collects a name, base URL, and API key before the + * user saves anything. Dismissing it on a backdrop click — or on a text + * selection drag that is released outside the card — unmounts the modal and + * wipes all of that input. The backdrop must therefore never close the + * modal; only the × button, Escape, and a successful add may. + */ + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; +let closeCalls: number; + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + originalFetch = globalThis.fetch; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + closeCalls = 0; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/oauth/providers") return Response.json({ providers: [] }); + if (url.pathname === "/api/provider-presets") return Response.json({ providers: [] }); + if (url.pathname === "/api/usage") return Response.json({ providers: [] }); + return Response.json({}); + }, + }); + + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + await win.happyDOM?.close?.(); +}); + +async function mountModal() { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + { closeCalls += 1; }} + onAdded={() => {}} + /> + , + ); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); +} + +function overlay(): HTMLElement { + const el = host.querySelector(".modal-overlay"); + expect(el).toBeTruthy(); + return el!; +} + +function nameInput(): HTMLInputElement { + const el = host.querySelector(".modal-card input.input"); + expect(el).toBeTruthy(); + return el!; +} + +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!; + setter.call(input, value); + input.dispatchEvent(new win.Event("input", { bubbles: true })); +} + +function click(target: HTMLElement) { + target.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); +} + +test("clicking the backdrop neither closes the modal nor discards entered form data", async () => { + await mountModal(); + + await act(async () => { setInputValue(nameInput(), "my-provider"); }); + expect(nameInput().value).toBe("my-provider"); + + await act(async () => { click(overlay()); }); + + expect(closeCalls).toBe(0); + expect(host.querySelector(".modal-overlay")).toBeTruthy(); + expect(nameInput().value).toBe("my-provider"); +}); + +test("a text-selection drag released on the backdrop does not close the modal", async () => { + await mountModal(); + + await act(async () => { setInputValue(nameInput(), "sk-live-key-material"); }); + + // Press inside the input (starting a selection drag), release on the overlay. + await act(async () => { + nameInput().dispatchEvent(new win.MouseEvent("mousedown", { bubbles: true })); + overlay().dispatchEvent(new win.MouseEvent("mouseup", { bubbles: true })); + click(overlay()); + }); + + expect(closeCalls).toBe(0); + expect(host.querySelector(".modal-overlay")).toBeTruthy(); + expect(nameInput().value).toBe("sk-live-key-material"); +}); + +test("the close button still closes the modal", async () => { + await mountModal(); + + const closeButton = host.querySelector(".modal-head button[aria-label='Close']"); + expect(closeButton).toBeTruthy(); + await act(async () => { click(closeButton!); }); + + expect(closeCalls).toBe(1); +}); + +test("Escape still closes the modal", async () => { + await mountModal(); + + await act(async () => { + win.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Escape" })); + }); + + expect(closeCalls).toBe(1); +});