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 (
+
+ );
+}
+
+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")}
+
+ )}
+
+
+ );
+}