Skip to content

Commit 55bd086

Browse files
authored
Merge pull request #210 from helpfulengineering/frontend-dev
Frontend dev
2 parents 545fc84 + a2ab664 commit 55bd086

34 files changed

Lines changed: 1912 additions & 365 deletions

frontend/e2e/okh-catalog.spec.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { test, expect } from "./mock-api";
2+
import { okhListEmptyFixture } from "../src/test/fixtures";
3+
4+
// Slice A1: faceted OKH design catalog. Mocked lane (default gate) covers the
5+
// facet behavior; the real-api lane only runs the lenient "loads" check.
6+
7+
test("faceted catalog loads with a filter panel", async ({ page }) => {
8+
await page.goto("/okh");
9+
await expect(
10+
page.getByRole("heading", { name: /open hardware designs/i }),
11+
).toBeVisible();
12+
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
13+
});
14+
15+
test("shows fixture designs (mocked)", async ({ page }, testInfo) => {
16+
test.skip(testInfo.project.name === "real-api", "asserts fixture data");
17+
await page.goto("/okh");
18+
await expect(page.getByRole("heading", { name: "Open Ventilator" })).toBeVisible();
19+
await expect(page.getByRole("heading", { name: "Face Shield" })).toBeVisible();
20+
await expect(page.getByRole("heading", { name: "Test Rig" })).toBeVisible();
21+
});
22+
23+
test("selecting a facet narrows the results (mocked)", async ({ page }, testInfo) => {
24+
test.skip(testInfo.project.name === "real-api", "asserts fixture data");
25+
await page.goto("/okh");
26+
// Only Face Shield is GPL-2.0.
27+
await page.getByRole("checkbox", { name: /GPL-2\.0/ }).check();
28+
await expect(page.getByRole("heading", { name: "Face Shield" })).toBeVisible();
29+
await expect(page.getByRole("heading", { name: "Open Ventilator" })).toBeHidden();
30+
// Facet selection is reflected in the URL (deep-linkable).
31+
await expect(page).toHaveURL(/license=GPL-2\.0/);
32+
});
33+
34+
test("category facet is the primary spine and narrows results (mocked)", async ({
35+
page,
36+
}, testInfo) => {
37+
test.skip(testInfo.project.name === "real-api", "asserts fixture data");
38+
await page.goto("/okh");
39+
// Category is present as a facet group. Test Rig ("Calibration test rig") is
40+
// the only Test & Measurement device.
41+
await expect(page.getByRole("heading", { name: "Category" })).toBeVisible();
42+
await page.getByRole("checkbox", { name: /Test & Measurement/ }).check();
43+
await expect(page.getByRole("heading", { name: "Test Rig" })).toBeVisible();
44+
await expect(page.getByRole("heading", { name: "Open Ventilator" })).toBeHidden();
45+
await expect(page).toHaveURL(/category=Test/);
46+
});
47+
48+
test("shows the empty state (mocked)", async ({ page }, testInfo) => {
49+
test.skip(testInfo.project.name === "real-api", "forces an empty response");
50+
await page.route("**/v1/api/okh**", (route) =>
51+
route.fulfill({ json: okhListEmptyFixture }),
52+
);
53+
await page.goto("/okh");
54+
await expect(page.getByText(/no designs/i)).toBeVisible();
55+
});
56+
57+
test("shows the error state with retry (mocked)", async ({ page }, testInfo) => {
58+
test.skip(testInfo.project.name === "real-api", "forces an error response");
59+
await page.route("**/v1/api/okh**", (route) =>
60+
route.fulfill({ status: 503, json: { message: "boom" } }),
61+
);
62+
await page.goto("/okh");
63+
await expect(page.getByRole("alert")).toBeVisible();
64+
await expect(page.getByRole("button", { name: /retry/i })).toBeVisible();
65+
});

frontend/e2e/okh-detail.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { test, expect } from "./mock-api";
2+
3+
// Slice 2 (#187): OKH design detail + validate. Mocked lane (the fixture id
4+
// okh-0001 isn't guaranteed in the live corpus).
5+
6+
test("shows OKH design detail (mocked)", async ({ page }, testInfo) => {
7+
test.skip(testInfo.project.name === "real-api", "uses a fixture id");
8+
await page.goto("/okh/okh-0001");
9+
await expect(page.getByRole("heading", { name: "Open Ventilator" })).toBeVisible();
10+
await expect(page.getByRole("heading", { name: "Design Info" })).toBeVisible();
11+
});
12+
13+
test("validate surfaces a validation result (mocked)", async ({ page }, testInfo) => {
14+
test.skip(testInfo.project.name === "real-api", "uses fixtures");
15+
await page.goto("/okh/okh-0001");
16+
await page.getByRole("button", { name: "Validate" }).click();
17+
await expect(page.getByRole("heading", { name: "Validation" })).toBeVisible();
18+
await expect(page.getByText(/Missing intended_use/)).toBeVisible();
19+
await expect(page.getByText(/Add a bill of materials/)).toBeVisible();
20+
});

frontend/e2e/okw-catalog.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { test, expect } from "./mock-api";
2+
import { okwSearchEmptyFixture } from "../src/test/fixtures";
3+
4+
// Slice #188: read-only OKW facility catalog. Mocked lane covers the behavior;
5+
// the real-api lane runs the lenient "loads" check against the live corpus.
6+
7+
test("facility catalog loads", async ({ page }) => {
8+
await page.goto("/facilities");
9+
await expect(
10+
page.getByRole("heading", { name: /manufacturing facilities/i }),
11+
).toBeVisible();
12+
});
13+
14+
test("shows fixture facilities with humanized processes (mocked)", async ({ page }, testInfo) => {
15+
test.skip(testInfo.project.name === "real-api", "asserts fixture data");
16+
await page.goto("/facilities");
17+
await expect(page.getByRole("heading", { name: "Laser Fab Lab" })).toBeVisible();
18+
await expect(page.getByRole("heading", { name: "Community Makerspace" })).toBeVisible();
19+
// Wikipedia URI is humanized for display.
20+
await expect(page.getByText("Laser Cutter")).toBeVisible();
21+
});
22+
23+
test("filtering by access type narrows results (mocked)", async ({ page }, testInfo) => {
24+
test.skip(testInfo.project.name === "real-api", "asserts fixture data");
25+
await page.goto("/facilities");
26+
await page.getByRole("button", { name: "Public", pressed: false }).click();
27+
await expect(page.getByRole("heading", { name: "Community Makerspace" })).toBeVisible();
28+
await expect(page.getByRole("heading", { name: "Laser Fab Lab" })).toBeHidden();
29+
});
30+
31+
test("shows the empty state (mocked)", async ({ page }, testInfo) => {
32+
test.skip(testInfo.project.name === "real-api", "forces an empty response");
33+
await page.route("**/v1/api/okw/search**", (route) =>
34+
route.fulfill({ json: okwSearchEmptyFixture }),
35+
);
36+
await page.goto("/facilities");
37+
await expect(page.getByText(/no facilities/i)).toBeVisible();
38+
});
39+
40+
test("shows the error state with retry (mocked)", async ({ page }, testInfo) => {
41+
test.skip(testInfo.project.name === "real-api", "forces an error response");
42+
await page.route("**/v1/api/okw/search**", (route) =>
43+
route.fulfill({ status: 500, json: { message: "boom" } }),
44+
);
45+
await page.goto("/facilities");
46+
await expect(page.getByRole("alert")).toBeVisible();
47+
await expect(page.getByRole("button", { name: /retry/i })).toBeVisible();
48+
});

frontend/harness.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@
77
"apiPathPrefix": "/v1/api",
88
"openapiUrl": "http://localhost:8001/v1/openapi.json",
99
"apiTypesOutput": "src/api/generated/schema.d.ts",
10-
"routesToScreenshot": ["/", "/okh", "/match"]
10+
"routesToScreenshot": ["/", "/okh", "/okh/okh-0001", "/facilities", "/match"]
1111
}

frontend/package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"echarts": "^6.1.0",
3030
"echarts-for-react": "^3.0.2",
3131
"lucide-react": "^1.23.0",
32+
"openapi-fetch": "^0.17.0",
3233
"react": "^19.0.0",
3334
"react-cytoscapejs": "^2.0.0",
3435
"react-dom": "^19.0.0",

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
33
import { Layout } from "./components/layout/Layout";
44
import { HomePage } from "./pages/HomePage";
55
import { OkhPage } from "./pages/OkhPage";
6+
import { OkwPage } from "./pages/OkwPage";
67
import { MatchPage } from "./pages/MatchPage";
78
import { VisualizationPage } from "./pages/VisualizationPage";
89
import { RfqPage } from "./pages/RfqPage";
@@ -31,6 +32,7 @@ export function App() {
3132
<Route index element={<HomePage />} />
3233
<Route path="okh" element={<OkhPage />} />
3334
<Route path="okh/:id" element={<OkhPage />} />
35+
<Route path="facilities" element={<OkwPage />} />
3436
<Route path="match" element={<MatchPage />} />
3537
<Route path="visualization" element={<VisualizationPage />} />
3638
<Route path="visualization/:solutionId" element={<VisualizationPage />} />

frontend/src/api/ohm/client.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Typed OHM API client (module 1 of the frontend architecture).
3+
*
4+
* Built on `openapi-fetch` over the types generated from the backend OpenAPI
5+
* spec (`src/api/generated/schema.d.ts`). Paths, query params, and response
6+
* envelopes are type-checked against the real contract, so backend drift is a
7+
* compile error. Per-domain wrappers (e.g. `okh.ts`) live alongside this and
8+
* expose small, intention-revealing functions to the rest of the app.
9+
*
10+
* The generated `paths` are rooted at `/api/...`; the versioned app is mounted
11+
* at `/v1` (proxied to the OHM API in dev), so the base URL is `/v1`.
12+
*/
13+
import createClient from "openapi-fetch";
14+
import type { paths } from "../generated/schema";
15+
16+
// Absolute base so requests work identically in the browser (real origin +
17+
// dev-proxy) and in the node/jsdom test env, where undici's fetch rejects
18+
// relative URLs. In a browser this resolves to the current origin + /v1.
19+
const origin =
20+
typeof window !== "undefined" && window.location?.origin
21+
? window.location.origin
22+
: "http://localhost";
23+
24+
export const apiClient = createClient<paths>({
25+
baseUrl: `${origin}/v1`,
26+
// Defer to the *current* global fetch on each call rather than the reference
27+
// captured at module load, so test-time interceptors (MSW) that replace
28+
// globalThis.fetch after import are honored. No-op difference in the browser.
29+
fetch: (input) => globalThis.fetch(input),
30+
});
31+
32+
/** Thrown by domain wrappers when a request fails; carries the HTTP status. */
33+
export class ApiError extends Error {
34+
constructor(
35+
public readonly status: number,
36+
message: string,
37+
) {
38+
super(message);
39+
this.name = "ApiError";
40+
}
41+
}
42+
43+
/** Best-effort human message from a JSON error body ({message} or {detail}). */
44+
export function errorMessage(body: unknown, fallback: string): string {
45+
if (body && typeof body === "object") {
46+
const b = body as { message?: unknown; detail?: unknown };
47+
if (typeof b.message === "string") return b.message;
48+
if (typeof b.detail === "string") return b.detail;
49+
}
50+
return fallback;
51+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { http, HttpResponse } from "msw";
2+
import { describe, expect, it } from "vitest";
3+
import { server } from "../../test/msw/server";
4+
import { ApiError } from "./client";
5+
import { fetchOkhDetail, validateOkh } from "./okh";
6+
7+
describe("fetchOkhDetail", () => {
8+
it("returns the manifest for an id", async () => {
9+
const okh = await fetchOkhDetail("okh-0001");
10+
expect(okh.title).toBe("Open Ventilator");
11+
});
12+
13+
it("throws ApiError on failure", async () => {
14+
server.use(
15+
http.get("*/v1/api/okh/:id", () =>
16+
HttpResponse.json({ message: "not found" }, { status: 404 }),
17+
),
18+
);
19+
await expect(fetchOkhDetail("missing")).rejects.toMatchObject({
20+
name: "ApiError",
21+
status: 404,
22+
});
23+
await expect(fetchOkhDetail("missing")).rejects.toBeInstanceOf(ApiError);
24+
});
25+
});
26+
27+
describe("validateOkh", () => {
28+
it("returns a validation result", async () => {
29+
const res = await validateOkh({ title: "x" });
30+
expect(res.is_valid).toBe(true);
31+
expect(res.score).toBeCloseTo(0.92);
32+
});
33+
34+
it("posts content in the body and quality/strict as query params", async () => {
35+
let body: { content?: { title?: string } } | null = null;
36+
let q: URLSearchParams | null = null;
37+
server.use(
38+
http.post("*/v1/api/okh/validate", async ({ request }) => {
39+
body = (await request.json()) as { content?: { title?: string } };
40+
q = new URL(request.url).searchParams;
41+
return HttpResponse.json({ is_valid: true, score: 1 });
42+
}),
43+
);
44+
await validateOkh(
45+
{ title: "Widget" },
46+
{ qualityLevel: "professional", strictMode: true },
47+
);
48+
expect(body!.content!.title).toBe("Widget");
49+
expect(q!.get("quality_level")).toBe("professional");
50+
expect(q!.get("strict_mode")).toBe("true");
51+
});
52+
});

frontend/src/api/ohm/okh.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { http, HttpResponse } from "msw";
2+
import { describe, expect, it } from "vitest";
3+
import { server } from "../../test/msw/server";
4+
import { ApiError } from "./client";
5+
import { fetchOkhList } from "./okh";
6+
7+
describe("fetchOkhList", () => {
8+
it("returns narrowed items and pagination from the paginated envelope", async () => {
9+
const result = await fetchOkhList();
10+
expect(result.items).toHaveLength(3);
11+
expect(result.items.map((i) => i.title)).toContain("Open Ventilator");
12+
expect(result.pagination.total_items).toBe(3);
13+
});
14+
15+
it("passes paging/sort/filter as query params", async () => {
16+
let captured: URLSearchParams | null = null;
17+
server.use(
18+
http.get("*/v1/api/okh", ({ request }) => {
19+
captured = new URL(request.url).searchParams;
20+
return HttpResponse.json({ items: [], pagination: { total_items: 0 } });
21+
}),
22+
);
23+
await fetchOkhList({ page: 2, page_size: 10, sort_by: "title", sort_order: "asc", filter: "vent" });
24+
expect(captured!.get("page")).toBe("2");
25+
expect(captured!.get("page_size")).toBe("10");
26+
expect(captured!.get("sort_by")).toBe("title");
27+
expect(captured!.get("filter")).toBe("vent");
28+
});
29+
30+
it("throws ApiError with the HTTP status on failure", async () => {
31+
server.use(
32+
http.get("*/v1/api/okh", () =>
33+
HttpResponse.json({ message: "boom" }, { status: 503 }),
34+
),
35+
);
36+
await expect(fetchOkhList()).rejects.toMatchObject({
37+
name: "ApiError",
38+
status: 503,
39+
});
40+
await expect(fetchOkhList()).rejects.toBeInstanceOf(ApiError);
41+
});
42+
});

0 commit comments

Comments
 (0)