Fix unsaved agent config navigation guard

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-22 19:30:26 +00:00
1 parent 6aa50ea1fc
commit 6d813f0678
3 files changed
+111 -9

No files matched your search

@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { useState } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import {
createMemoryRouter,
RouterProvider,
useLocation,
useNavigate,
} from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useUnsavedNavigationGuard } from "./use-unsaved-navigation-guard";
describe("useUnsavedNavigationGuard", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
vi.restoreAllMocks();
});
it("prompts once and keeps dirty state when navigation is cancelled", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
function GuardedPage() {
const navigate = useNavigate();
const location = useLocation();
const [dirty] = useState(true);
useUnsavedNavigationGuard(dirty, "Discard changes?");
return (
<>
<button
type="button"
onClick={() => {
navigate("/dashboard");
}}
>
Dashboard
</button>
<span data-location>{location.pathname}</span>
<span data-dirty>{String(dirty)}</span>
</>
);
}
const router = createMemoryRouter(
[{ path: "*", element: <GuardedPage /> }],
{ initialEntries: ["/agents/test/configuration"] },
);
flushSync(() => {
root.render(<RouterProvider router={router} />);
});
await new Promise((resolve) => setTimeout(resolve, 20));
flushSync(() => {
container.querySelector("button")?.click();
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(confirm).toHaveBeenCalledTimes(1);
expect(container.querySelector("[data-location]")?.textContent).toBe("/agents/test/configuration");
expect(container.querySelector("[data-dirty]")?.textContent).toBe("true");
router.dispose();
});
});
@@ -0,0 +1,25 @@
import { useEffect, useRef } from "react";
import { useBlocker } from "@/lib/router";
export function useUnsavedNavigationGuard(when: boolean, message: string) {
const blocker = useBlocker(when);
const promptActiveRef = useRef(false);
useEffect(() => {
if (blocker.state !== "blocked") {
promptActiveRef.current = false;
return;
}
if (promptActiveRef.current) return;
promptActiveRef.current = true;
if (window.confirm(message)) {
setTimeout(blocker.proceed, 0);
} else {
blocker.reset();
}
}, [blocker, message]);
return blocker;
}
+8 -9
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, Link, Navigate, useBeforeUnload, useBlocker, type NavigateFunction } from "@/lib/router";
import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
agentsApi,
@@ -38,6 +38,7 @@ import {
type AgentConfigurationSectionId,
} from "../components/agent-configuration-shell";
import { PageTabBar } from "../components/PageTabBar";
import { useUnsavedNavigationGuard } from "@/lib/use-unsaved-navigation-guard";
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { useAdapterCapabilities } from "@/adapters/use-adapter-capabilities";
@@ -742,12 +743,7 @@ export function AgentDetail() {
event.preventDefault();
event.returnValue = "";
}, [configDirty]));
const navigationBlocker = useBlocker(configDirty);
useEffect(() => {
if (navigationBlocker.state !== "blocked") return;
if (window.confirm("Discard your unsaved agent configuration changes?")) navigationBlocker.proceed();
else navigationBlocker.reset();
}, [navigationBlocker]);
useUnsavedNavigationGuard(configDirty, "Discard your unsaved agent configuration changes?");
const { data: agent, isLoading, error } = useQuery<AgentDetailRecord>({
queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null],
@@ -756,6 +752,9 @@ export function AgentDetail() {
});
const resolvedCompanyId = agent?.companyId ?? selectedCompanyId;
const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef;
const handleViewChange = useCallback((value: string) => {
navigate(`/agents/${canonicalAgentRef}/${value}`);
}, [canonicalAgentRef, navigate]);
const agentLookupRef = agent?.id ?? routeAgentRef;
const resolvedAgentId = agent?.id ?? null;
const membershipsQuery = useResourceMemberships(resolvedCompanyId);
@@ -1282,7 +1281,7 @@ export function AgentDetail() {
{!urlRunId && (
<Tabs
value={activeView}
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
onValueChange={isMobile ? undefined : handleViewChange}
>
<PageTabBar
items={[
@@ -1295,7 +1294,7 @@ export function AgentDetail() {
{ value: "budget", label: "Budget" },
]}
value={activeView}
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
onValueChange={isMobile ? handleViewChange : undefined}
/>
</Tabs>
)}