diff --git a/web/src/app.test.tsx b/web/src/app.test.tsx index 593f6f4..dc9ca27 100644 --- a/web/src/app.test.tsx +++ b/web/src/app.test.tsx @@ -1,7 +1,17 @@ import { render, screen } from "@testing-library/react"; -import { App } from "./app"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -test("renders the app placeholder", () => { - render(); - expect(screen.getByRole("heading", { name: /collection/i })).toBeInTheDocument(); +import { App } from "./app"; +import "./i18n"; + +test("mounts and routes to a known screen", async () => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + render( + + + , + ); + + expect(await screen.findByText(/object|föremål|sign in|logga in/i)).toBeInTheDocument(); }); diff --git a/web/src/app.tsx b/web/src/app.tsx index fe3c7fc..8b51ea6 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -1,3 +1,24 @@ +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; + +import { RequireAuth } from "./auth/require-auth"; +import { LoginPage } from "./auth/login-page"; +import { AppShell } from "./shell/app-shell"; +import { ObjectsPage } from "./objects/objects-page"; + export function App() { - return

Collection

; + return ( + + + } /> + }> + }> + } /> + } /> + } /> + + + } /> + + + ); } diff --git a/web/src/main.tsx b/web/src/main.tsx index eed4988..871fcd6 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,11 +1,19 @@ -import "./index.css"; -import "./i18n"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + import { App } from "./app"; +import "./index.css"; +import "./i18n"; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, +}); createRoot(document.getElementById("root")!).render( - + + + , ); diff --git a/web/src/objects/object-detail.test.tsx b/web/src/objects/object-detail.test.tsx new file mode 100644 index 0000000..a62a68b --- /dev/null +++ b/web/src/objects/object-detail.test.tsx @@ -0,0 +1,48 @@ +import { expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { Routes, Route } from "react-router-dom"; +import { server } from "../test/server"; +import { renderApp } from "../test/render"; +import { ObjectDetail } from "./object-detail"; + +function tree() { + return ( + + } /> + + ); +} + +test("renders inventory-minimum fields, flexible values and visibility", async () => { + // override so the object carries a flexible field value (schema types fields as + // Record, so return a plain object literal here) + server.use( + http.get("/api/admin/objects/:id", () => + HttpResponse.json({ + id: "11111111-1111-1111-1111-111111111111", + object_number: "LM-0042", + object_name: "Amphora", + number_of_objects: 1, + brief_description: "Storage jar", + current_location: "Vault 3", + current_owner: null, + recorder: null, + recording_date: null, + visibility: "public", + fields: { material: "Bronze" }, + }), + ), + ); + renderApp(tree(), { route: "/objects/11111111-1111-1111-1111-111111111111" }); + expect(await screen.findByText("Amphora")).toBeInTheDocument(); + expect(screen.getByText("Vault 3")).toBeInTheDocument(); + expect(screen.getByText("Bronze")).toBeInTheDocument(); // flexible field value + expect(screen.getByText("Public")).toBeInTheDocument(); +}); + +test("shows a not-found state for a missing object", async () => { + server.use(http.get("/api/admin/objects/:id", () => new HttpResponse(null, { status: 404 }))); + renderApp(tree(), { route: "/objects/does-not-exist" }); + expect(await screen.findByText(/object not found/i)).toBeInTheDocument(); +}); diff --git a/web/src/objects/object-detail.tsx b/web/src/objects/object-detail.tsx new file mode 100644 index 0000000..c848ba5 --- /dev/null +++ b/web/src/objects/object-detail.tsx @@ -0,0 +1,77 @@ +import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +import { useObject, useFieldDefinitions } from "../api/queries"; +import { VisibilityBadge } from "./visibility-badge"; +import { Skeleton } from "@/components/ui/skeleton"; + +function Field({ + label, + value, +}: { + label: string; + value: string | number | null | undefined; +}) { + if (value === null || value === undefined || value === "") return null; + + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function ObjectDetail() { + const { t } = useTranslation(); + const { id } = useParams(); + const { data: object, isLoading, isError } = useObject(id!); + const { data: definitions } = useFieldDefinitions(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) return

{t("objects.loadError")}

; + + if (!object) return

{t("objects.notFound")}

; + + const labelFor = (key: string) => + definitions?.find((d) => d.key === key)?.labels.find((l) => l.lang === "en")?.label ?? key; + + const flexible = Object.entries(object.fields as Record); + + return ( +
+
+

{object.object_name}

+ +
+ + + + + + + + {flexible.length > 0 && ( +
+
+ {t("fieldsLabels.flexible")} +
+ {flexible.map(([key, value]) => ( + + ))} +
+ )} +
+ ); +} diff --git a/web/src/objects/object-list.tsx b/web/src/objects/object-list.tsx index b68cb89..64a179d 100644 --- a/web/src/objects/object-list.tsx +++ b/web/src/objects/object-list.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { NavLink, useParams } from "react-router-dom"; +import { NavLink } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -11,7 +11,6 @@ const LIMIT = 50; export function ObjectList() { const { t } = useTranslation(); - const { id: selectedId } = useParams(); const [offset, setOffset] = useState(0); const { data, isLoading, isError } = useObjectsPage(LIMIT, offset); @@ -44,9 +43,11 @@ export function ObjectList() {
  • + `flex items-center justify-between gap-2 border-b px-3 py-2 text-sm ${ + isActive ? "bg-indigo-50" : "hover:bg-neutral-50" + }` + } > {object.object_number}{" "} diff --git a/web/src/objects/objects-page.test.tsx b/web/src/objects/objects-page.test.tsx new file mode 100644 index 0000000..94066d3 --- /dev/null +++ b/web/src/objects/objects-page.test.tsx @@ -0,0 +1,23 @@ +import { expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Routes, Route } from "react-router-dom"; +import { renderApp } from "../test/render"; +import { ObjectsPage } from "./objects-page"; + +function tree() { + return ( + + } /> + } /> + + ); +} + +test("selecting a row shows its detail in the right pane", async () => { + renderApp(tree(), { route: "/objects" }); + // Wait for both the prompt (right pane) and the list rows (left pane) to load. + await screen.findByText(/select an object/i); + await userEvent.click(await screen.findByText("Amphora")); + expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument(); +}); diff --git a/web/src/objects/objects-page.tsx b/web/src/objects/objects-page.tsx new file mode 100644 index 0000000..86c2597 --- /dev/null +++ b/web/src/objects/objects-page.tsx @@ -0,0 +1,27 @@ +import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +import { ObjectList } from "./object-list"; +import { ObjectDetail } from "./object-detail"; + +export function ObjectsPage() { + const { t } = useTranslation(); + const { id } = useParams(); + + return ( +
    +
    + +
    +
    + {id ? ( + + ) : ( +
    + {t("objects.selectPrompt")} +
    + )} +
    +
    + ); +}