Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/apps/web/src/App.site-map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ vi.mock("./context/KitManifestSaveContext", () => ({
vi.mock("./context/SaveStatusContext", () => ({
SaveStatusProvider: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("./components/BuildSaveNavigationGuard", () => ({ default: () => null }));
vi.mock("./components/AuthGate", () => ({
default: function AuthGate() {
return <Outlet />;
Expand Down
2 changes: 2 additions & 0 deletions web/apps/web/src/AuthenticatedApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ProfileProvider } from "./context/ProfileContext";
import { SaveStatusProvider } from "./context/SaveStatusContext";
import { StlAutoSyncProvider } from "./context/StlAutoSyncContext";
import AppLayout from "./layout/AppLayout";
import BuildSaveNavigationGuard from "./components/BuildSaveNavigationGuard";
import { buildSourcesRoute } from "./lib/routes";

const BuildPage = lazy(() => import("./pages/BuildPage"));
Expand Down Expand Up @@ -53,6 +54,7 @@ export default function AuthenticatedApp() {
<SaveStatusProvider>
<ImportRulesSaveProvider>
<KitManifestSaveProvider>
<BuildSaveNavigationGuard />
<Routes>
<Route element={<AppLayout />}>
<Route index element={<IndexRedirect />} />
Expand Down
136 changes: 136 additions & 0 deletions web/apps/web/src/components/BuildSaveNavigationGuard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// @vitest-environment jsdom

import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createMemoryRouter, Link, RouterProvider, useLocation, useNavigate } from "react-router-dom";
import BuildSaveNavigationGuard from "./BuildSaveNavigationGuard";

const saves = vi.hoisted(() => ({ flush: vi.fn<() => Promise<void>>() }));
vi.mock("../hooks/useFlushBuildPageSaves", () => ({
useFlushBuildPageSaves: () => saves.flush,
}));

function renderRoutes(initialEntries = ["/builds", "/sources"], initialIndex = 1) {
const router = createMemoryRouter(
[
{
path: "*",
element: (
<>
<BuildSaveNavigationGuard />
<LocationProbe />
</>
),
},
],
{ initialEntries, initialIndex },
);
render(<RouterProvider router={router} />);
return router;
}

function LocationProbe() {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<h1>{location.pathname}</h1>
<Link to="/builds">Builds link</Link>
<button onClick={() => navigate("/builds")}>Builds button</button>
</>
);
}

describe("BuildSaveNavigationGuard", () => {
afterEach(() => {
cleanup();
saves.flush.mockReset();
});

it("holds browser Back until a pending save finishes", async () => {
let resolveSave!: () => void;
saves.flush.mockReturnValue(new Promise<void>((resolve) => {
resolveSave = resolve;
}));
const router = renderRoutes();
act(() => {
void router.navigate(-1);
});
expect(router.state.location.pathname).toBe("/sources");
expect(saves.flush).toHaveBeenCalledTimes(1);
resolveSave();
await waitFor(() => expect(router.state.location.pathname).toBe("/builds"));
});

it("keeps Sources open when a save fails and allows a later retry", async () => {
saves.flush.mockRejectedValueOnce(new Error("offline")).mockResolvedValue(undefined);
const router = renderRoutes();
act(() => {
void router.navigate(-1);
});
await waitFor(() => expect(saves.flush).toHaveBeenCalledTimes(1));
await waitFor(() => expect([...router.state.blockers.values()][0]?.state).toBe("unblocked"));
expect(router.state.location.pathname).toBe("/sources");
act(() => {
void router.navigate(-1);
});
await waitFor(() => expect(router.state.location.pathname).toBe("/builds"));
});

it("holds Forward, links, and programmatic navigation", async () => {
for (const navigation of ["forward", "link", "button"] as const) {
saves.flush.mockReset();
let resolveSave!: () => void;
saves.flush.mockReturnValue(new Promise<void>((resolve) => {
resolveSave = resolve;
}));
const router = navigation === "forward"
? renderRoutes(["/sources", "/builds"], 0)
: renderRoutes(["/sources"], 0);
if (navigation === "forward") {
act(() => {
void router.navigate(1);
});
} else {
fireEvent.click(screen.getByRole(navigation === "link" ? "link" : "button", {
name: navigation === "link" ? "Builds link" : "Builds button",
}));
}
expect(router.state.location.pathname).toBe("/sources");
expect(saves.flush).toHaveBeenCalledTimes(1);
resolveSave();
await waitFor(() => expect(router.state.location.pathname).toBe("/builds"));
cleanup();
}
});

it("flushes Plan choices before leaving but ignores unrelated query changes", async () => {
saves.flush.mockResolvedValue(undefined);
const router = renderRoutes(["/plan?profile=1"], 0);
act(() => {
void router.navigate("/plan?profile=1&tab=parts");
});
await waitFor(() => expect(router.state.location.search).toBe("?profile=1&tab=parts"));
expect(saves.flush).not.toHaveBeenCalled();
act(() => {
void router.navigate("/builds");
});
await waitFor(() => expect(router.state.location.pathname).toBe("/builds"));
expect(saves.flush).toHaveBeenCalledTimes(1);
});

it("keeps the original Build on a failed same-route profile switch", async () => {
saves.flush.mockRejectedValueOnce(new Error("offline")).mockResolvedValue(undefined);
const router = renderRoutes(["/sources?profile=1"], 0);
act(() => {
void router.navigate("/sources?profile=2");
});
await waitFor(() => expect([...router.state.blockers.values()][0]?.state).toBe("unblocked"));
expect(router.state.location.search).toBe("?profile=1");
act(() => {
void router.navigate("/sources?profile=2");
});
await waitFor(() => expect(router.state.location.search).toBe("?profile=2"));
expect(saves.flush).toHaveBeenCalledTimes(2);
});
});
37 changes: 37 additions & 0 deletions web/apps/web/src/components/BuildSaveNavigationGuard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useEffect, useRef } from "react";
import { useBlocker } from "react-router-dom";
import { toast } from "sonner";
import { useFlushBuildPageSaves } from "../hooks/useFlushBuildPageSaves";
import { isPlanPath, isSourcesPath } from "../lib/routes";

export default function BuildSaveNavigationGuard() {
const flushSaves = useFlushBuildPageSaves();
const handlingRef = useRef(false);
const blocker = useBlocker(
({ currentLocation, nextLocation }) => {
if (!isSourcesPath(currentLocation.pathname) && !isPlanPath(currentLocation.pathname)) {
return false;
}
if (currentLocation.pathname !== nextLocation.pathname) return true;
const currentProfile = new URLSearchParams(currentLocation.search).get("profile");
const nextProfile = new URLSearchParams(nextLocation.search).get("profile");
return currentProfile !== nextProfile;
},
);

useEffect(() => {
if (blocker.state !== "blocked" || handlingRef.current) return;
handlingRef.current = true;
void flushSaves()
.then(() => blocker.proceed())
.catch(() => {
blocker.reset();
toast.error("Save failed. Retry before leaving Sources or Plan.");
})
.finally(() => {
handlingRef.current = false;
});
}, [blocker, flushSaves]);

return null;
}
82 changes: 82 additions & 0 deletions web/apps/web/src/components/KitManifestOptions.navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// @vitest-environment jsdom

import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createMemoryRouter, RouterProvider, useLocation } from "react-router-dom";
import BuildSaveNavigationGuard from "./BuildSaveNavigationGuard";
import KitManifestOptions from "./KitManifestOptions";
import { ImportRulesSaveProvider } from "../context/ImportRulesSaveContext";
import { KitManifestSaveProvider } from "../context/KitManifestSaveContext";

const mocks = vi.hoisted(() => ({ save: vi.fn() }));

vi.mock("../api/endpoints/planManifests", async (importOriginal) => {
const actual = await importOriginal<typeof import("../api/endpoints/planManifests")>();
return {
...actual,
fetchPlanManifestBuilder: vi.fn().mockResolvedValue({
merged_option_groups: {
variants: {
rule: "pick_one",
label: "Variants",
parts: [],
variants: [
{ id: "stock", label: "Stock", parts: [] },
{ id: "custom", label: "Custom", parts: [] },
],
},
},
resolved_selections: { variants: "stock" },
}),
fetchPlanKitManifest: vi.fn().mockResolvedValue({
name: null,
layers: [],
base_source_id: null,
addon_source_ids: [],
selections: {},
include: [],
exclude: [],
replacements: {},
choice_tree: [],
category_links: [],
}),
savePlanKitManifest: mocks.save,
};
});

describe("KitManifestOptions guarded navigation", () => {
afterEach(() => {
cleanup();
mocks.save.mockReset();
});

it("keeps a failed variant edit on Plan when browser Back also fails to save", async () => {
mocks.save.mockRejectedValue(new Error("offline"));
function Page() {
const location = useLocation();
return location.pathname === "/plan" ? <KitManifestOptions profileId={2} /> : <h1>Sources</h1>;
}
const router = createMemoryRouter([{
path: "*",
element: (
<ImportRulesSaveProvider>
<KitManifestSaveProvider>
<BuildSaveNavigationGuard />
<Page />
</KitManifestSaveProvider>
</ImportRulesSaveProvider>
),
}], { initialEntries: ["/sources?profile=2", "/plan?profile=2"], initialIndex: 1 });
render(<RouterProvider router={router} />);

fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
await screen.findByText("Save failed — retry");
expect(mocks.save).toHaveBeenCalledTimes(1);
act(() => { void router.navigate(-1); });
await waitFor(() => expect(mocks.save).toHaveBeenCalledTimes(2));
await waitFor(() => expect([...router.state.blockers.values()][0]?.state).toBe("unblocked"));
expect(router.state.location.pathname).toBe("/plan");
expect(screen.getByRole("button", { name: "Custom" }).getAttribute("aria-pressed")).toBe("true");
});

});
102 changes: 102 additions & 0 deletions web/apps/web/src/components/PlanPicker.navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// @vitest-environment jsdom

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ProfileSummary } from "@print-partner/contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createMemoryRouter, RouterProvider, useLocation } from "react-router-dom";
import { PlanActionsProvider } from "../context/PlanActionsContext";
import { ProfileProvider, useProfileSelection } from "../context/ProfileContext";
import { useProfileUrlSync } from "../hooks/useProfileUrlSync";
import BuildSaveNavigationGuard from "./BuildSaveNavigationGuard";
import PlanPicker from "./PlanPicker";

const saves = vi.hoisted(() => ({ flush: vi.fn<() => Promise<void>>() }));
const profiles: ProfileSummary[] = [1, 2].map((id) => ({
id,
name: `Build ${id}`,
order_number: null,
special_request: null,
part_count: 0,
accepted_progress: { kind: "empty" },
build_stale: false,
freshness: {
status: "untracked",
accepted_input_set_id: null,
accepted_at: null,
reasons: [{ kind: "no_accepted_inputs" }],
},
archived_at: null,
last_used_at: null,
}));

vi.mock("../context/AuthContext", () => ({
useAuth: () => ({ user: null, multiUser: false, loading: false }),
}));
vi.mock("../hooks/useEngineHealth", () => ({
useEngineHealth: () => ({ health: { ok: true } }),
}));
vi.mock("../hooks/useFlushBuildPageSaves", () => ({
useFlushBuildPageSaves: () => saves.flush,
}));
vi.mock("../queries/profiles", () => {
const mutation = () => ({ isPending: false, mutate: vi.fn(), mutateAsync: vi.fn() });
return {
useProfilesQuery: () => ({ data: profiles, isLoading: false, isSuccess: true, error: null, refetch: vi.fn() }),
useCreateProfileMutation: mutation,
useUpdateProfileMutation: mutation,
useDeleteProfileMutation: mutation,
useDuplicateProfileMutation: mutation,
useArchiveProfileMutation: mutation,
useTouchProfileLastUsedMutation: mutation,
};
});

function Probe() {
useProfileUrlSync();
const location = useLocation();
const { selectedProfileId } = useProfileSelection();
return (
<>
<BuildSaveNavigationGuard />
<PlanPicker />
<output data-testid="route">{selectedProfileId}|{location.pathname}{location.search}</output>
</>
);
}

describe("PlanPicker guarded navigation", () => {
beforeEach(() => {
sessionStorage.clear();
vi.stubGlobal("ResizeObserver", class {
observe() {}
unobserve() {}
disconnect() {}
});
Element.prototype.scrollIntoView = vi.fn();
});
afterEach(() => {
cleanup();
saves.flush.mockReset();
vi.unstubAllGlobals();
});

it("keeps the selected Build and URL together when the save fails, then switches on retry", async () => {
saves.flush.mockRejectedValueOnce(new Error("offline")).mockResolvedValue(undefined);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const router = createMemoryRouter([
{ path: "*", element: <ProfileProvider><PlanActionsProvider><Probe /></PlanActionsProvider></ProfileProvider> },
], { initialEntries: ["/plan?profile=1"] });
render(<QueryClientProvider client={queryClient}><RouterProvider router={router} /></QueryClientProvider>);
await waitFor(() => expect(screen.getByTestId("route").textContent).toBe("1|/plan?profile=1"));

fireEvent.click(screen.getByRole("combobox", { name: "Select Build" }));
fireEvent.click(screen.getByText("Build 2"));
await waitFor(() => expect(saves.flush).toHaveBeenCalledTimes(1));
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
expect(screen.getByTestId("route").textContent).toBe("1|/plan?profile=1");

fireEvent.click(screen.getByText("Build 2"));
await waitFor(() => expect(screen.getByTestId("route").textContent).toBe("2|/plan?profile=2"));
});
});
Loading
Loading