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
14 changes: 14 additions & 0 deletions frontend/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ const router = createBrowserRouter([
/**
* Try fAIr route ends.
*/
{
path: APPLICATION_ROUTES.TRY_FAIR_PROJECT,
lazy: async () => {
const { TryFairProjectPage } =
await import("@/app/routes/try-fair/try-fair-projects");
return {
Component: () => (
<ProtectedRoute>
<TryFairProjectPage />
</ProtectedRoute>
),
};
},
},

/**
* Map Request result (full-screen, protected) route.
Expand Down
124 changes: 124 additions & 0 deletions frontend/src/app/routes/__tests__/projects.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { cleanup, render, renderHook, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PropsWithChildren } from "react";
import { TryFairProjectPage } from "../try-fair/try-fair-projects";
import { useGetUserState } from "@/features/user-profile/api/user-state";

const get = vi.hoisted(() => vi.fn());
vi.mock("@/services", () => ({
apiClient: { get },
API_ENDPOINTS: {
GET_SINGLE_USER_STATE: (pid: string) => `/user-state/${pid}/`,
},
}));
vi.mock("../try-fair", () => ({
TryFairPage: () => {
const { search } = useLocation();
return (
<div>
Mapping workspace<output data-testid="restored-search">{search}</output>
</div>
);
},
}));
vi.mock("@/components/seo", () => ({ Head: () => null }));
vi.mock("@/components/ui/spinner", () => ({ Spinner: () => null }));

const createWrapper = () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return ({ children }: PropsWithChildren) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
};

const renderProject = () =>
render(
<MemoryRouter
initialEntries={["/try-fair/42?model=stale-model&imagery=stale-imagery"]}
>
<Routes>
<Route path="/try-fair/:pid" element={<TryFairProjectPage />} />
</Routes>
</MemoryRouter>,
{ wrapper: createWrapper() },
);

describe("Saved Try fAIr project", () => {
beforeEach(() => {
get.mockReset();
});
afterEach(cleanup);

it("waits for the saved state before mounting the workspace", () => {
get.mockReturnValue(new Promise(() => {}));
renderProject();
expect(
screen.getByRole("status", { name: "Loading mapping project" }),
).toBeInTheDocument();
expect(screen.queryByText("Mapping workspace")).not.toBeInTheDocument();
});

it("fetches by the route pid and opens the workspace on success", async () => {
get.mockResolvedValue({
data: {
pid: 42,
state: {
type: "mapping",
category: "trees",
model: { id: "tree-model", title: "Trees" },
imagery: { name: "Survey", url: "https://example.com/tilejson.json" },
zoom: 19,
bbox: [85.5, 27.6, 85.52, 27.63],
url_params: {
model: "tree-model",
selectedModelId: "tree-model",
feature: "trees",
mode: "imagery",
output: "points",
resolution: "mid",
confidence: 0.42,
mappingMode: "advanced",
imagery: "https://example.com/tilejson.json",
imageryType: "TileJSON",
oamItem: null,
chooseLocation: false,
},
},
},
});
renderProject();
expect(await screen.findByText("Mapping workspace")).toBeInTheDocument();
expect(get).toHaveBeenCalledWith("/user-state/42/", {
signal: expect.any(AbortSignal),
});
const search = new URLSearchParams(
screen.getByTestId("restored-search").textContent ?? "",
);
expect(search.get("model")).toBe("tree-model");
expect(search.get("feature")).toBe("trees");
expect(search.get("imagery")).toBe("https://example.com/tilejson.json");
expect(search.get("confidence")).toBe("0.42");
expect(search.get("mappingMode")).toBe("advanced");
});

it("shows an error instead of the workspace when fetching fails", async () => {
get.mockRejectedValue(new Error("Not found"));
renderProject();
expect(await screen.findByRole("alert")).toHaveTextContent(
"Couldn't load this mapping project",
);
expect(screen.queryByText("Mapping workspace")).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Start a new mapping project" }),
).toHaveAttribute("href", "/try-fair");
});

it("does not fetch a saved state without a pid", () => {
renderHook(() => useGetUserState(), { wrapper: createWrapper() });
expect(get).not.toHaveBeenCalled();
});
});
50 changes: 41 additions & 9 deletions frontend/src/app/routes/map-request-result.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,22 @@ const getPredictionName = (prediction: TOfflinePrediction): string =>
prediction.local_model_stac_id ||
`Prediction #${prediction.id}`;

/** Best-effort feature label derived from the model id (e.g. "…-buildings"). */
const getFeatureMapped = (prediction: TOfflinePrediction): string => {
const slug = prediction.local_model_stac_id ?? "";
const last = slug.split("-").pop() ?? "";
return last ? last.charAt(0).toUpperCase() + last.slice(1) : "—";
};

/** Info card shown over the map — styled like the Try fAIr sidebar. */
const ResultInfoCard = ({
imageryName,
featureMapped,
outputType,
onOutputTypeChange,
zoom,
params,
className,
}: {
imageryName: string;
featureMapped: string;
outputType: TryFairMapOutputType;
onOutputTypeChange: (type: TryFairMapOutputType) => void;
zoom: TOfflinePrediction["zoom"];
params: TOfflinePrediction["params"];
className?: string;
}) => (
<div
Expand Down Expand Up @@ -109,6 +106,36 @@ const ResultInfoCard = ({
))}
</div>
</div>
<details
aria-label="Prediction configuration"
className="border-t border-gray-border pt-4"
>
<summary className="cursor-pointer text-dark text-xs font-semibold">
Prediction configuration
</summary>
<dl className="mt-3 space-y-3 text-xs">
<div className="flex items-start justify-between gap-3">
<dt className="text-grey">Zoom level</dt>
<dd className="text-dark font-medium tabular-nums">{zoom}</dd>
</div>
{Object.entries(params ?? {}).map(([key, value]) => (
<div key={key} className="flex items-start justify-between gap-3">
<dt
className="min-w-0 break-words capitalize text-grey"
title={key}
>
{key.replace(/_/g, " ")}
</dt>
<dd className="max-w-[45%] break-words text-right font-medium tabular-nums text-dark">
{value}
</dd>
</div>
))}
</dl>
{Object.keys(params ?? {}).length === 0 && (
<p className="mt-3 text-xs text-grey">No parameters recorded.</p>
)}
</details>
</div>
);

Expand Down Expand Up @@ -205,9 +232,12 @@ export const MapRequestResultPage = () => {
<div className="absolute top-4 left-4 z-10">
<ResultInfoCard
imageryName={oamItem?.title || "Custom Imagery"}
featureMapped={getFeatureMapped(prediction)}
featureMapped={prediction?.category ?? "-"}
outputType={outputType}
onOutputTypeChange={setOutputType}
zoom={prediction.zoom}
params={prediction.params}
className="max-h-[calc(92vh-2rem)] overflow-y-auto"
/>
</div>
)}
Expand All @@ -224,9 +254,11 @@ export const MapRequestResultPage = () => {
>
<ResultInfoCard
imageryName={oamItem?.title || "Custom Imagery"}
featureMapped={getFeatureMapped(prediction)}
featureMapped={prediction?.category ?? ""}
outputType={outputType}
onOutputTypeChange={setOutputType}
zoom={prediction.zoom}
params={prediction.params}
className="w-full shadow-none"
/>
</MobileDrawer>
Expand Down
Loading
Loading