refactor(web): migrate to data router (createBrowserRouter) to enable useBlocker (#46)
Convert app.tsx route tree verbatim to a module-level data router via createRoutesFromElements + RouterProvider, and the test harness to createMemoryRouter + RouterProvider. The search NavLink-click test now mounts its routes as real data-router routes so RouterProvider intercepts the link (descendant <Routes> under a catch-all let it fall through to a jsdom navigation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+46
-44
@@ -1,5 +1,5 @@
|
|||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense } from "react";
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
import { createBrowserRouter, createRoutesFromElements, Navigate, Route, RouterProvider } from "react-router-dom";
|
||||||
|
|
||||||
import { RequireAuth } from "./auth/require-auth";
|
import { RequireAuth } from "./auth/require-auth";
|
||||||
import { LoginPage } from "./auth/login-page";
|
import { LoginPage } from "./auth/login-page";
|
||||||
@@ -29,55 +29,57 @@ function FormFallback() {
|
|||||||
return <div role="status" className="p-4 text-sm text-muted-foreground">Loading…</div>;
|
return <div role="status" className="p-4 text-sm text-muted-foreground">Loading…</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
const router = createBrowserRouter(
|
||||||
return (
|
createRoutesFromElements(
|
||||||
<BrowserRouter>
|
<>
|
||||||
<Routes>
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<AppShell />}>
|
||||||
<Route element={<AppShell />}>
|
<Route
|
||||||
|
path="/objects/new"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<FormFallback />}>
|
||||||
|
<ObjectNewPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/objects" element={<ObjectsPage />}>
|
||||||
|
<Route path=":id" element={<ObjectDetail />} />
|
||||||
<Route
|
<Route
|
||||||
path="/objects/new"
|
path=":id/edit"
|
||||||
element={
|
element={
|
||||||
<Suspense fallback={<FormFallback />}>
|
<Suspense fallback={<FormFallback />}>
|
||||||
<ObjectNewPage />
|
<ObjectEditForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/objects" element={<ObjectsPage />}>
|
|
||||||
<Route path=":id" element={<ObjectDetail />} />
|
|
||||||
<Route
|
|
||||||
path=":id/edit"
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<FormFallback />}>
|
|
||||||
<ObjectEditForm />
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
<Route path="/vocabularies" element={<VocabulariesPage />}>
|
|
||||||
<Route index element={<SelectVocabularyPrompt />} />
|
|
||||||
<Route path=":id" element={<VocabularyTerms />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="/search" element={<SearchPage />}>
|
|
||||||
<Route index element={<SelectSearchPrompt />} />
|
|
||||||
<Route path=":id" element={<ObjectDetail />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="/authorities" element={<Navigate to="/authorities/person" replace />} />
|
|
||||||
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
|
|
||||||
<Route
|
|
||||||
path="/fields"
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<FormFallback />}>
|
|
||||||
<FieldsPage />
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="/" element={<Navigate to="/objects" replace />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/vocabularies" element={<VocabulariesPage />}>
|
||||||
|
<Route index element={<SelectVocabularyPrompt />} />
|
||||||
|
<Route path=":id" element={<VocabularyTerms />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/search" element={<SearchPage />}>
|
||||||
|
<Route index element={<SelectSearchPrompt />} />
|
||||||
|
<Route path=":id" element={<ObjectDetail />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/authorities" element={<Navigate to="/authorities/person" replace />} />
|
||||||
|
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
|
||||||
|
<Route
|
||||||
|
path="/fields"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<FormFallback />}>
|
||||||
|
<FieldsPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/" element={<Navigate to="/objects" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/objects" replace />} />
|
</Route>
|
||||||
</Routes>
|
<Route path="*" element={<Navigate to="/objects" replace />} />
|
||||||
</BrowserRouter>
|
</>,
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { screen, waitFor, within } from "@testing-library/react";
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { Route, Routes } from "react-router-dom";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createMemoryRouter, Route, RouterProvider, Routes } from "react-router-dom";
|
||||||
|
|
||||||
import { server } from "../test/server";
|
import { server } from "../test/server";
|
||||||
import { renderApp } from "../test/render";
|
import { renderApp } from "../test/render";
|
||||||
@@ -10,6 +11,7 @@ import { amphora } from "../test/fixtures";
|
|||||||
import { SearchPage } from "./search-page";
|
import { SearchPage } from "./search-page";
|
||||||
import { SelectSearchPrompt } from "./select-search-prompt";
|
import { SelectSearchPrompt } from "./select-search-prompt";
|
||||||
import { ObjectDetail } from "../objects/object-detail";
|
import { ObjectDetail } from "../objects/object-detail";
|
||||||
|
import "../i18n";
|
||||||
|
|
||||||
function tree() {
|
function tree() {
|
||||||
return (
|
return (
|
||||||
@@ -22,6 +24,35 @@ function tree() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The search rows are <NavLink>s. Under the shared `renderApp` harness the test
|
||||||
|
// subtree lives in a descendant <Routes> under a catch-all `*` data route, where
|
||||||
|
// the data router does not intercept the link click (it falls through to a real
|
||||||
|
// browser navigation that jsdom rejects). Mounting the search routes as real
|
||||||
|
// data-router routes lets RouterProvider intercept the NavLink, which is the
|
||||||
|
// data-router equivalent of the old MemoryRouter behavior.
|
||||||
|
function renderSearchRouter(route = "/search") {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const router = createMemoryRouter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: "/search",
|
||||||
|
element: <SearchPage />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <SelectSearchPrompt /> },
|
||||||
|
{ path: ":id", element: <ObjectDetail /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ initialEntries: [route] },
|
||||||
|
);
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test("typing searches and renders highlighted rich rows", async () => {
|
test("typing searches and renders highlighted rich rows", async () => {
|
||||||
renderApp(tree(), { route: "/search" });
|
renderApp(tree(), { route: "/search" });
|
||||||
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
|
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
|
||||||
@@ -69,7 +100,7 @@ test("empty query shows the prompt; zero results shows empty", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("clicking a result shows the object in the detail pane", async () => {
|
test("clicking a result shows the object in the detail pane", async () => {
|
||||||
renderApp(tree(), { route: "/search" });
|
renderSearchRouter();
|
||||||
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
|
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
|
||||||
await userEvent.click(await screen.findByText("Bronze figurine"));
|
await userEvent.click(await screen.findByText("Bronze figurine"));
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { render } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { createMemoryRouter, RouterProvider } from "react-router-dom";
|
||||||
|
|
||||||
import "../i18n";
|
import "../i18n";
|
||||||
|
|
||||||
export function renderApp(ui: ReactElement, { route = "/" } = {}) {
|
export function renderApp(ui: ReactElement, { route = "/" } = {}) {
|
||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const router = createMemoryRouter([{ path: "*", element: ui }], { initialEntries: [route] });
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>
|
<RouterProvider router={router} />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user