mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
Fix unsaved agent config navigation guard
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user