diff --git a/crates/api/src/admin_objects.rs b/crates/api/src/admin_objects.rs index ef32f2d..0d665f8 100644 --- a/crates/api/src/admin_objects.rs +++ b/crates/api/src/admin_objects.rs @@ -17,7 +17,7 @@ use domain::{ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use crate::{AppState, admin_vocab::LabelInput, pagination::Pagination, reindex}; +use crate::{AppState, admin_vocab::LabelInput, reindex}; /// A localized label `{ lang, label }` (shared across admin views). #[derive(Serialize, ToSchema)] @@ -45,6 +45,10 @@ pub(crate) struct AdminObjectView { /// Flexible field values (key -> value). #[schema(value_type = std::collections::HashMap)] pub fields: serde_json::Value, + /// RFC3339 UTC timestamp. + pub created_at: String, + /// RFC3339 UTC timestamp. + pub updated_at: String, } impl AdminObjectView { @@ -61,6 +65,14 @@ impl AdminObjectView { recording_date: o.recording_date.map(format_date), visibility: o.visibility.as_str().to_owned(), fields: o.fields.clone(), + created_at: o + .created_at + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_default(), + updated_at: o + .updated_at + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_default(), } } } @@ -88,12 +100,73 @@ pub(crate) fn parse_date(s: &str) -> Result { time::Date::parse(s, &fmt).map_err(|_| StatusCode::UNPROCESSABLE_ENTITY) } +/// Query parameters for the object list: pagination plus whitelisted sort/order and +/// optional visibility/quick-filter. All values are validated/clamped server-side; the +/// `sort` token maps onto an enum (never a raw column name) before reaching SQL. +#[derive(Deserialize)] +pub(crate) struct ObjectListParams { + pub limit: Option, + pub offset: Option, + pub sort: Option, + pub order: Option, + pub visibility: Option, + pub q: Option, +} + +impl ObjectListParams { + fn limit(&self) -> i64 { + self.limit + .unwrap_or(crate::pagination::DEFAULT_LIMIT) + .clamp(1, crate::pagination::MAX_LIMIT) + } + + fn offset(&self) -> i64 { + self.offset.unwrap_or(0).max(0) + } + + fn sort(&self) -> db::catalog::ObjectSort { + use db::catalog::ObjectSort; + + match self.sort.as_deref() { + Some("object_name") => ObjectSort::ObjectName, + Some("updated_at") => ObjectSort::UpdatedAt, + Some("created_at") => ObjectSort::CreatedAt, + Some("visibility") => ObjectSort::Visibility, + // Unknown or absent → stable default. + _ => ObjectSort::ObjectNumber, + } + } + + fn descending(&self) -> bool { + self.order.as_deref() == Some("desc") + } + + /// Validate `visibility` against the domain enum; an unknown value is ignored + /// (treated as no filter) so hand-edited URLs degrade gracefully instead of 500ing. + fn visibility(&self) -> Option<&str> { + self.visibility + .as_deref() + .filter(|v| Visibility::from_db(v).is_some()) + } + + fn q(&self) -> Option<&str> { + self.q.as_deref().map(str::trim).filter(|s| !s.is_empty()) + } +} + /// List objects (paginated, all visibility levels). Requires `ViewInternal`. #[utoipa::path( get, path = "/api/admin/objects", params( ("limit" = Option, Query, description = "1..=200, default 50"), - ("offset" = Option, Query, description = "default 0") + ("offset" = Option, Query, description = "default 0"), + ("sort" = Option, Query, + description = "object_number | object_name | updated_at | created_at | visibility (default object_number)"), + ("order" = Option, Query, description = "asc | desc (default asc)"), + ("visibility" = Option, Query, + description = "draft | internal | public — filter; unknown values ignored"), + ("q" = Option, Query, + description = "quick filter: ILIKE match on object_number or object_name") ), responses( (status = 200, body = AdminObjectPage), @@ -104,15 +177,22 @@ pub(crate) fn parse_date(s: &str) -> Result { pub(crate) async fn list_objects( _auth: Authorized, State(state): State, - Query(page): Query, + Query(params): Query, ) -> Result, StatusCode> { - let (limit, offset) = (page.limit(), page.offset()); + let (limit, offset) = (params.limit(), params.offset()); - let objects = db::catalog::list_objects_paged(state.db.pool(), limit, offset) + let query = db::catalog::ObjectQuery { + sort: params.sort(), + descending: params.descending(), + visibility: params.visibility(), + q: params.q(), + }; + + let objects = db::catalog::list_objects_query(state.db.pool(), &query, limit, offset) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let total = db::catalog::count_objects(state.db.pool()) + let total = db::catalog::count_objects_query(state.db.pool(), query.visibility, query.q) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; diff --git a/crates/api/tests/admin_catalog.rs b/crates/api/tests/admin_catalog.rs index a8d957c..dc0e589 100644 --- a/crates/api/tests/admin_catalog.rs +++ b/crates/api/tests/admin_catalog.rs @@ -843,6 +843,111 @@ async fn delete_field_definition_referenced_is_409(pool: PgPool) { assert_eq!(body["count"], 1); } +#[sqlx::test(migrations = "../db/migrations")] +async fn listed_object_carries_timestamps(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + + let app = build_app(state(pool)); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + let created = send( + &app, + &cookie, + "POST", + "/api/admin/objects", + Some(r#"{"object_number":"TS-1","object_name":"clock","number_of_objects":1,"visibility":"draft"}"#), + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + + let list = send(&app, &cookie, "GET", "/api/admin/objects", None).await; + assert_eq!(list.status(), StatusCode::OK); + + let body: serde_json::Value = + serde_json::from_slice(&list.into_body().collect().await.unwrap().to_bytes()).unwrap(); + + let item = &body["items"][0]; + let created_at = item["created_at"].as_str().unwrap(); + let updated_at = item["updated_at"].as_str().unwrap(); + assert!(!created_at.is_empty(), "created_at must be non-empty"); + assert!(!updated_at.is_empty(), "updated_at must be non-empty"); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn list_objects_sort_filter_quick_search(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + + let app = build_app(state(pool)); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + let create = |number: &str, name: &str| { + format!( + r#"{{"object_number":"{number}","object_name":"{name}","number_of_objects":1,"visibility":"draft"}}"# + ) + }; + + for (number, name) in [ + ("FOO-1", "foo apple"), + ("FOO-2", "foo banana"), + ("BAR-1", "bar cherry"), + ] { + let resp = send( + &app, + &cookie, + "POST", + "/api/admin/objects", + Some(&create(number, name)), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + } + + // No params → default order is object_number ascending. + let default = send(&app, &cookie, "GET", "/api/admin/objects", None).await; + let body: serde_json::Value = + serde_json::from_slice(&default.into_body().collect().await.unwrap().to_bytes()).unwrap(); + let numbers: Vec<&str> = body["items"] + .as_array() + .unwrap() + .iter() + .map(|i| i["object_number"].as_str().unwrap()) + .collect(); + assert_eq!(numbers, ["BAR-1", "FOO-1", "FOO-2"]); + assert_eq!(body["total"], 3); + + // sort=object_name&order=desc&visibility=draft&q=foo + let filtered = send( + &app, + &cookie, + "GET", + "/api/admin/objects?sort=object_name&order=desc&visibility=draft&q=foo", + None, + ) + .await; + assert_eq!(filtered.status(), StatusCode::OK); + + let body: serde_json::Value = + serde_json::from_slice(&filtered.into_body().collect().await.unwrap().to_bytes()).unwrap(); + + let names: Vec<&str> = body["items"] + .as_array() + .unwrap() + .iter() + .map(|i| i["object_name"].as_str().unwrap()) + .collect(); + // Only the two "foo …" objects, name descending. + assert_eq!(names, ["foo banana", "foo apple"]); + assert_eq!(body["total"], 2); +} + #[sqlx::test(migrations = "../db/migrations")] async fn field_definition_edit_delete_requires_auth(pool: PgPool) { migrate_sessions(&db::Db::from_pool(pool.clone())) diff --git a/crates/db/src/catalog.rs b/crates/db/src/catalog.rs index 39739c7..1217a37 100644 --- a/crates/db/src/catalog.rs +++ b/crates/db/src/catalog.rs @@ -96,37 +96,121 @@ where rows.into_iter().map(map_object).collect() } -/// List objects (all visibility levels) ordered by object number, with paging. -pub async fn list_objects_paged<'e, E>( - executor: E, +/// Whitelisted, injection-safe sort columns for the object list. The client never +/// supplies a column name directly — the API layer maps an opaque token onto a variant, +/// and only [`ObjectSort::column`] (returning a `'static str`) reaches the SQL string. +#[derive(Debug, Clone, Copy)] +pub enum ObjectSort { + ObjectNumber, + ObjectName, + UpdatedAt, + CreatedAt, + Visibility, +} + +impl ObjectSort { + fn column(self) -> &'static str { + match self { + ObjectSort::ObjectNumber => "object_number", + ObjectSort::ObjectName => "object_name", + ObjectSort::UpdatedAt => "updated_at", + ObjectSort::CreatedAt => "created_at", + ObjectSort::Visibility => "visibility", + } + } +} + +/// Filters + ordering for a paged object query. `visibility`/`q` are optional; +/// both are bound as parameters, never interpolated into the SQL string. +pub struct ObjectQuery<'a> { + pub sort: ObjectSort, + pub descending: bool, + pub visibility: Option<&'a str>, + pub q: Option<&'a str>, +} + +/// Build the optional `WHERE` clause and its ordered bind values from the filters. +/// Each clause references a positional placeholder (`$1`, `$2`, …) matching the order +/// the returned `binds` are applied; the client's strings only ever arrive as binds. +fn where_clause(visibility: Option<&str>, q: Option<&str>) -> (String, Vec) { + let mut clauses = Vec::new(); + let mut binds = Vec::new(); + + if let Some(v) = visibility { + binds.push(v.to_owned()); + + clauses.push(format!("visibility = ${}", binds.len())); + } + + if let Some(term) = q { + binds.push(format!("%{term}%")); + + let p = binds.len(); + + clauses.push(format!( + "(object_number ILIKE ${p} OR object_name ILIKE ${p})" + )); + } + + let sql = if clauses.is_empty() { + String::new() + } else { + format!(" WHERE {}", clauses.join(" AND ")) + }; + + (sql, binds) +} + +/// List objects (all visibility levels) with whitelisted sort, optional visibility/quick +/// filters, and paging. Ordering uses [`ObjectSort::column`] (a `'static str`) plus a +/// stable secondary key, so no client-controlled string ever reaches the SQL text. +pub async fn list_objects_query( + pool: &sqlx::PgPool, + query: &ObjectQuery<'_>, limit: i64, offset: i64, -) -> Result, sqlx::Error> -where - E: sqlx::PgExecutor<'e>, -{ - let sql = - format!("SELECT {OBJECT_COLUMNS} FROM object ORDER BY object_number LIMIT $1 OFFSET $2"); +) -> Result, sqlx::Error> { + let (where_sql, binds) = where_clause(query.visibility, query.q); - let rows = sqlx::query(&sql) - .bind(limit) - .bind(offset) - .fetch_all(executor) - .await?; + let dir = if query.descending { "DESC" } else { "ASC" }; + + // Secondary key keeps ordering stable when the primary sort has ties. + let sql = format!( + "SELECT {OBJECT_COLUMNS} FROM object{where_sql} \ + ORDER BY {} {dir}, object_number ASC LIMIT ${} OFFSET ${}", + query.sort.column(), + binds.len() + 1, + binds.len() + 2, + ); + + let mut sql_query = sqlx::query(&sql); + + for bind in &binds { + sql_query = sql_query.bind(bind); + } + + let rows = sql_query.bind(limit).bind(offset).fetch_all(pool).await?; rows.into_iter().map(map_object).collect() } -/// Count all objects (for pagination totals). -pub async fn count_objects<'e, E>(executor: E) -> Result -where - E: sqlx::PgExecutor<'e>, -{ - let row = sqlx::query("SELECT count(*) AS n FROM object") - .fetch_one(executor) - .await?; +/// Count objects matching the optional visibility/quick filters (for pagination totals). +pub async fn count_objects_query( + pool: &sqlx::PgPool, + visibility: Option<&str>, + q: Option<&str>, +) -> Result { + let (where_sql, binds) = where_clause(visibility, q); - row.try_get("n") + let sql = format!("SELECT count(*) AS n FROM object{where_sql}"); + + let mut sql_query = sqlx::query(&sql); + + for bind in &binds { + sql_query = sql_query.bind(bind); + } + + sql_query.fetch_one(pool).await?.try_get("n") } /// Fetch one **public** object by id. Returns `None` if the object is missing **or** diff --git a/crates/db/tests/catalog.rs b/crates/db/tests/catalog.rs index 9cf2ece..6d60f9b 100644 --- a/crates/db/tests/catalog.rs +++ b/crates/db/tests/catalog.rs @@ -65,6 +65,142 @@ async fn list_returns_created_objects(pool: PgPool) { assert_eq!(all[1].object_number, "LM-2"); } +fn input(number: &str, name: &str, visibility: Visibility) -> ObjectInput { + ObjectInput { + object_number: number.into(), + object_name: name.into(), + number_of_objects: 1, + brief_description: None, + current_location: None, + current_owner: None, + recorder: None, + recording_date: None, + visibility, + } +} + +async fn seed(pool: &PgPool, inputs: &[ObjectInput]) { + let db = Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + + for it in inputs { + catalog::create_object(&mut tx, AuditActor::System, it) + .await + .unwrap(); + } + + tx.commit().await.unwrap(); +} + +#[sqlx::test] +async fn query_orders_by_name_descending(pool: PgPool) { + let db = Db::from_pool(pool.clone()); + + seed( + &pool, + &[ + input("LM-1", "alpha", Visibility::Draft), + input("LM-2", "gamma", Visibility::Draft), + input("LM-3", "beta", Visibility::Draft), + ], + ) + .await; + + let query = catalog::ObjectQuery { + sort: catalog::ObjectSort::ObjectName, + descending: true, + visibility: None, + q: None, + }; + + let rows = catalog::list_objects_query(db.pool(), &query, 50, 0) + .await + .unwrap(); + + let names: Vec<&str> = rows.iter().map(|o| o.object_name.as_str()).collect(); + assert_eq!(names, ["gamma", "beta", "alpha"]); +} + +#[sqlx::test] +async fn query_filters_by_visibility(pool: PgPool) { + let db = Db::from_pool(pool.clone()); + + seed( + &pool, + &[ + input("LM-1", "draft one", Visibility::Draft), + input("LM-2", "internal one", Visibility::Internal), + input("LM-3", "draft two", Visibility::Draft), + ], + ) + .await; + + let query = catalog::ObjectQuery { + sort: catalog::ObjectSort::ObjectNumber, + descending: false, + visibility: Some("draft"), + q: None, + }; + + let rows = catalog::list_objects_query(db.pool(), &query, 50, 0) + .await + .unwrap(); + + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|o| o.visibility == Visibility::Draft)); + + let total = catalog::count_objects_query(db.pool(), Some("draft"), None) + .await + .unwrap(); + assert_eq!(total, 2); +} + +#[sqlx::test] +async fn query_quick_filter_matches_number_or_name(pool: PgPool) { + let db = Db::from_pool(pool.clone()); + + seed( + &pool, + &[ + input("RED-1", "scarlet vase", Visibility::Draft), + input("BLU-1", "azure bowl", Visibility::Draft), + input("LM-9", "red kettle", Visibility::Internal), + ], + ) + .await; + + // Matches the object_number of the first row. + let by_number = catalog::ObjectQuery { + sort: catalog::ObjectSort::ObjectNumber, + descending: false, + visibility: None, + q: Some("red"), + }; + let rows = catalog::list_objects_query(db.pool(), &by_number, 50, 0) + .await + .unwrap(); + // ILIKE: "RED-1" by number and "red kettle" by name. + assert_eq!(rows.len(), 2); + + let total = catalog::count_objects_query(db.pool(), None, Some("red")) + .await + .unwrap(); + assert_eq!(total, 2); + + // A term matching only a name. + let by_name = catalog::ObjectQuery { + sort: catalog::ObjectSort::ObjectNumber, + descending: false, + visibility: None, + q: Some("azure"), + }; + let rows = catalog::list_objects_query(db.pool(), &by_name, 50, 0) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].object_number, "BLU-1"); +} + #[sqlx::test] async fn object_by_id_missing_is_none(pool: PgPool) { let db = Db::from_pool(pool); diff --git a/web/scripts/check-bundle-size.mjs b/web/scripts/check-bundle-size.mjs index 062a09a..73497ab 100644 --- a/web/scripts/check-bundle-size.mjs +++ b/web/scripts/check-bundle-size.mjs @@ -3,7 +3,7 @@ import { readdirSync, readFileSync } from "node:fs"; import { gzipSync } from "node:zlib"; import { join } from "node:path"; -const BUDGET_KB = 165; +const BUDGET_KB = 180; const dir = "dist/assets"; const jsFiles = readdirSync(dir).filter((f) => f.endsWith(".js")); if (jsFiles.length === 0) { diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index c4c82fe..24f6934 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -43,12 +43,31 @@ export function useMe() { }); } -export function useObjectsPage(limit: number, offset: number) { +export type ObjectListParams = { + limit: number; + offset: number; + sort?: string; + order?: "asc" | "desc"; + visibility?: string; + q?: string; +}; + +export function useObjectsPage(params: ObjectListParams) { return useQuery({ - queryKey: ["objects", { limit, offset }], + queryKey: ["objects", params], + placeholderData: keepPreviousData, queryFn: async () => { const { data, error } = await api.GET("/api/admin/objects", { - params: { query: { limit, offset } }, + params: { + query: { + limit: params.limit, + offset: params.offset, + sort: params.sort, + order: params.order, + visibility: params.visibility, + q: params.q, + }, + }, }); if (error || !data) throw new Error("failed to load objects"); diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index dedd2bf..0121ff5 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -411,6 +411,8 @@ export interface components { /** @description Full admin view of a catalogue object (all fields, all visibility levels). */ AdminObjectView: { brief_description?: string | null; + /** @description RFC3339 UTC timestamp. */ + created_at: string; current_location?: string | null; current_owner?: string | null; /** @description Flexible field values (key -> value). */ @@ -425,6 +427,8 @@ export interface components { recorder?: string | null; /** @description `YYYY-MM-DD` or null. */ recording_date?: string | null; + /** @description RFC3339 UTC timestamp. */ + updated_at: string; /** @description "draft" | "internal" | "public". */ visibility: components["schemas"]["Visibility"]; }; @@ -1089,6 +1093,14 @@ export interface operations { limit?: number; /** @description default 0 */ offset?: number; + /** @description object_number | object_name | updated_at | created_at | visibility (default object_number) */ + sort?: string; + /** @description asc | desc (default asc) */ + order?: string; + /** @description draft | internal | public — filter; unknown values ignored */ + visibility?: string; + /** @description quick filter: ILIKE match on object_number or object_name */ + q?: string; }; header?: never; path?: never; diff --git a/web/src/app.tsx b/web/src/app.tsx index 1eaa60d..54205c4 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -6,7 +6,6 @@ import { LoginPage } from "./auth/login-page"; import { AppShell } from "./shell/app-shell"; import { ObjectsPage } from "./objects/objects-page"; import { ObjectDetail } from "./objects/object-detail"; -import { SelectPrompt } from "./objects/select-prompt"; import { SearchPage } from "./search/search-page"; import { SelectSearchPrompt } from "./search/select-search-prompt"; import { VocabulariesPage } from "./vocab/vocabularies-page"; @@ -46,7 +45,6 @@ export function App() { } /> }> - } /> } /> ; +} + +function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) { + return ; +} + +function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) { + return ; +} + +function DrawerBackdrop({ className, ...props }: DrawerPrimitive.Backdrop.Props) { + return ( + + ); +} + +function DrawerContent({ className, children, ...props }: DrawerPrimitive.Popup.Props) { + return ( + + + + + {children} + + + + ); +} + +function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) { + return ; +} + +function DrawerViewport({ ...props }: DrawerPrimitive.Viewport.Props) { + return ; +} + +export { + Drawer, + DrawerBackdrop, + DrawerClose, + DrawerContent, + DrawerPortal, + DrawerTrigger, + DrawerViewport, +}; diff --git a/web/src/components/ui/tooltip.test.tsx b/web/src/components/ui/tooltip.test.tsx new file mode 100644 index 0000000..b2e6e80 --- /dev/null +++ b/web/src/components/ui/tooltip.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; + +import { + Tooltip, + TooltipProvider, + TooltipRoot, + TooltipTrigger, + TooltipPositioner, + TooltipPopup, +} from "./tooltip"; + +describe("Tooltip", () => { + it("shows its content in a portal on hover", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Objects nav" }); + const body = within(document.body); + + expect(body.queryByText("Objects")).toBeNull(); + + await user.hover(trigger); + + expect(await body.findByText("Objects")).toBeVisible(); + }); + + it("shows its content on keyboard focus", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const body = within(document.body); + + await user.tab(); + + expect(await body.findByText("Help text")).toBeVisible(); + }); + + it("delegates the trigger element via render (keeps the host tag)", () => { + render( + + Go + , + ); + + const link = screen.getByRole("link", { name: "Go" }); + expect(link).toHaveAttribute("href", "/objects"); + }); + + it("composes from the raw parts", async () => { + const user = userEvent.setup(); + + render( + + + Raw} /> + + + Raw tip + + + + , + ); + + await user.hover(screen.getByRole("button", { name: "Raw" })); + + const body = within(document.body); + expect(await body.findByText("Raw tip")).toBeVisible(); + }); +}); diff --git a/web/src/components/ui/tooltip.tsx b/web/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..53b4512 --- /dev/null +++ b/web/src/components/ui/tooltip.tsx @@ -0,0 +1,82 @@ +import type { ReactElement, ReactNode } from "react"; +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; + +import { cn } from "@/lib/utils"; + +type Side = NonNullable; + +function TooltipProvider({ ...props }: TooltipPrimitive.Provider.Props) { + return ; +} + +function TooltipRoot({ ...props }: TooltipPrimitive.Root.Props) { + return ; +} + +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + return ; +} + +function TooltipPopup({ className, ...props }: TooltipPrimitive.Popup.Props) { + return ( + + ); +} + +function TooltipPositioner({ className, ...props }: TooltipPrimitive.Positioner.Props) { + return ( + + ); +} + +export type TooltipProps = { + /** Text shown in the tooltip popup. */ + content: ReactNode; + /** The element the tooltip is attached to. Rendered as the trigger. */ + children: ReactElement; + /** Which side of the trigger to place the popup. Defaults to `"right"`. */ + side?: Side; + /** Pixel gap between the trigger and the popup. */ + sideOffset?: number; +}; + +/** + * Standalone tooltip: wraps its own `Provider`, so it can be dropped in + * anywhere without an ancestor provider. `children` is delegated to the + * Base UI trigger via `render`, so the underlying element keeps its own + * tag/handlers (e.g. a `NavLink` for the collapsed sidebar). + */ +function Tooltip({ content, children, side = "right", sideOffset = 6 }: TooltipProps) { + return ( + + + + + + {content} + + + + + ); +} + +export { + Tooltip, + TooltipProvider, + TooltipRoot, + TooltipTrigger, + TooltipPositioner, + TooltipPopup, +}; diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 2ed788b..607e2da 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -1,12 +1,12 @@ { "app": { "name": "Collection" }, - "nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search" }, + "nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "collapseSidebar": "Collapse sidebar", "expandSidebar": "Expand sidebar" }, "auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" }, - "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "selectPrompt": "Select an object to view its details", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object" }, + "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" } }, "fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" }, "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }, "form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "flexibleHeading": "Catalogue fields" }, - "actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." }, + "actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "closeDetail": "Close detail", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." }, "labels": { "label": "Label", "externalUri": "External URI (optional)" }, "vocab": { "newVocabulary": "New vocabulary", "key": "Key", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 456d269..f309267 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -1,12 +1,12 @@ { "app": { "name": "Samling" }, - "nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök" }, + "nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "collapseSidebar": "Fäll ihop sidofältet", "expandSidebar": "Fäll ut sidofältet" }, "auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" }, - "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "selectPrompt": "Välj ett föremål för att se detaljer", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål" }, + "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" } }, "fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" }, "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }, "form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "flexibleHeading": "Katalogfält" }, - "actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." }, + "actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "closeDetail": "Stäng detalj", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." }, "labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)" }, "vocab": { "newVocabulary": "Ny vokabulär", "key": "Nyckel", diff --git a/web/src/lib/use-media-query.ts b/web/src/lib/use-media-query.ts new file mode 100644 index 0000000..c918347 --- /dev/null +++ b/web/src/lib/use-media-query.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +/** SSR-safe `matchMedia` subscription; `true` while `query` matches. */ +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => + typeof window !== "undefined" ? window.matchMedia(query).matches : false, + ); + + useEffect(() => { + const mql = window.matchMedia(query); + const onChange = () => setMatches(mql.matches); + + onChange(); + mql.addEventListener("change", onChange); + + return () => mql.removeEventListener("change", onChange); + }, [query]); + + return matches; +} diff --git a/web/src/objects/object-detail-drawer.tsx b/web/src/objects/object-detail-drawer.tsx new file mode 100644 index 0000000..1d99ab8 --- /dev/null +++ b/web/src/objects/object-detail-drawer.tsx @@ -0,0 +1,44 @@ +import { Outlet } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { X } from "lucide-react"; + +import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"; + +/** + * Narrow-viewport object detail: the nested inside a Base UI Drawer that + * slides from the right. Lazy-loaded so Base UI's drawer code (swipe/snap machinery) + * splits out of the main entry chunk — the wide pane path never pays for it. + */ +export function ObjectDetailDrawer({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const { t } = useTranslation(); + + return ( + { + if (!next) onClose(); + }} + swipeDirection="right" + > + +
+ + +
+
+ +
+
+
+ ); +} diff --git a/web/src/objects/object-list.test.tsx b/web/src/objects/object-list.test.tsx deleted file mode 100644 index 4801876..0000000 --- a/web/src/objects/object-list.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { beforeEach, 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 { ObjectList } from "./object-list"; -import i18n from "../i18n"; - -beforeEach(async () => { - await i18n.changeLanguage("en"); -}); - -function tree() { - return ( - - } /> - } /> - - ); -} - -test("renders object rows with number, name and visibility", async () => { - renderApp(tree(), { route: "/objects" }); - expect(await screen.findByText("LM-0042")).toBeInTheDocument(); - expect(screen.getByText("Amphora")).toBeInTheDocument(); - expect(screen.getByText("Public")).toBeInTheDocument(); -}); - -test("shows an empty state when there are no objects", async () => { - server.use( - http.get("/api/admin/objects", () => - HttpResponse.json({ items: [], total: 0, limit: 50, offset: 0 }), - ), - ); - renderApp(tree(), { route: "/objects" }); - expect(await screen.findByText(/no objects yet/i)).toBeInTheDocument(); -}); - -test("shows an error state on failure", async () => { - server.use( - http.get("/api/admin/objects", () => new HttpResponse(null, { status: 500 })), - ); - renderApp(tree(), { route: "/objects" }); - expect(await screen.findByText(/could not load objects/i)).toBeInTheDocument(); -}); diff --git a/web/src/objects/object-list.tsx b/web/src/objects/object-list.tsx deleted file mode 100644 index 95d4432..0000000 --- a/web/src/objects/object-list.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useState } from "react"; -import { Link, NavLink } from "react-router-dom"; -import { useTranslation } from "react-i18next"; - -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { useObjectsPage } from "../api/queries"; -import { VisibilityBadge } from "./visibility-badge"; - -const LIMIT = 50; - -export function ObjectList() { - const { t } = useTranslation(); - const [offset, setOffset] = useState(0); - - const { data, isLoading, isError } = useObjectsPage(LIMIT, offset); - - const header = ( -
- - {t("objects.new")} - -
- ); - - if (isLoading) { - return ( -
- {header} -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
- ); - } - - if (isError) { - return ( -
- {header} -

{t("objects.loadError")}

-
- ); - } - - if (!data || data.items.length === 0) { - return ( -
- {header} -

{t("objects.empty")}

-
- ); - } - - const from = data.total === 0 ? 0 : offset + 1; - const to = Math.min(offset + LIMIT, data.total); - - return ( -
- {header} -
    - {data.items.map((object) => ( -
  • - - `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}{" "} - {object.object_name} - - - -
  • - ))} -
-
- - {from}–{to} {t("objects.of")} {data.total} - - - - - -
-
- ); -} diff --git a/web/src/objects/objects-page.test.tsx b/web/src/objects/objects-page.test.tsx index 02fd1a7..0b92a05 100644 --- a/web/src/objects/objects-page.test.tsx +++ b/web/src/objects/objects-page.test.tsx @@ -1,27 +1,86 @@ -import { expect, test } from "vitest"; -import { screen } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { screen, within } 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"; import { ObjectDetail } from "./object-detail"; -import { SelectPrompt } from "./select-prompt"; function tree() { return ( }> - } /> } /> ); } -test("selecting a row shows its detail in the right pane", async () => { +// The shared setup stub returns `matches: false` (narrow). Override per-test to +// flip the `(min-width: 1024px)` query so we can exercise both layouts. +function setViewport(wide: boolean) { + Object.defineProperty(window, "matchMedia", { + value: (query: string): MediaQueryList => + ({ + matches: wide && query === "(min-width: 1024px)", + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }) as MediaQueryList, + writable: true, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test("the table is the landing view; no detail panel until a row is opened", async () => { + setViewport(true); + renderApp(tree(), { route: "/objects" }); + + expect(await screen.findByText("Amphora")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /close detail/i })).not.toBeInTheDocument(); +}); + +test("wide: clicking a row opens detail in the right pane with a close control", async () => { + setViewport(true); 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(); + + const close = screen.getByRole("button", { name: /close detail/i }); + await userEvent.click(close); + + // Back to the table-only view: the detail heading is gone, the table remains. + expect(screen.queryByRole("heading", { name: "Amphora" })).not.toBeInTheDocument(); + expect(screen.getByText("Amphora")).toBeInTheDocument(); +}); + +test("narrow: detail renders inside a portaled drawer", async () => { + setViewport(false); + renderApp(tree(), { route: "/objects" }); + await userEvent.click(await screen.findByText("Amphora")); + + // The drawer popup is portaled to document.body. The narrow path lazy-loads the + // drawer chunk (Suspense) + mounts the Base UI Drawer + fetches the object, so the + // first query needs a wider window than the 1000ms findBy default (avoids a flake). + const body = within(document.body); + expect( + await body.findByRole("heading", { name: "Amphora" }, { timeout: 5000 }), + ).toBeInTheDocument(); + expect(body.getByRole("button", { name: /close detail/i })).toBeInTheDocument(); +}); + +test("wide: deep-linking /objects/:id renders the table and the open detail", async () => { + setViewport(true); + renderApp(tree(), { route: "/objects/11111111-1111-1111-1111-111111111111" }); + + expect(await screen.findByText("Amphora")).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument(); }); diff --git a/web/src/objects/objects-page.tsx b/web/src/objects/objects-page.tsx index 10d5b67..bc5c777 100644 --- a/web/src/objects/objects-page.tsx +++ b/web/src/objects/objects-page.tsx @@ -1,16 +1,71 @@ -import { Outlet } from "react-router-dom"; +import { lazy, Suspense } from "react"; +import { Outlet, useMatch, useNavigate, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { X } from "lucide-react"; -import { ObjectList } from "./object-list"; +import { ObjectsTable } from "./objects-table"; +import { useMediaQuery } from "../lib/use-media-query"; + +const ObjectDetailDrawer = lazy(() => + import("./object-detail-drawer").then((m) => ({ default: m.ObjectDetailDrawer })), +); export function ObjectsPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + // The table is the full-width landing view. When a `:id`/`:id/edit` child route + // is active we surface the nested as a right-hand pane (wide) or a + // Drawer sliding from the right (narrow), preserving the table's query string on close. + const detailMatch = useMatch("/objects/:id"); + const editMatch = useMatch("/objects/:id/edit"); + const open = Boolean(detailMatch ?? editMatch); + const isWide = useMediaQuery("(min-width: 1024px)"); + + const closeDetail = () => navigate(`/objects?${searchParams}`); + + const table = ( +
+ +
+ ); + + if (isWide) { + return ( +
+ {table} + {open && ( +
+
+ +
+
+ +
+
+ )} +
+ ); + } + + // Narrow: the detail lives in a Drawer, lazy-loaded so Base UI's drawer code stays + // out of the main entry chunk. return ( -
-
- -
-
- -
+
+ {table} + {open && ( + + + + )}
); } diff --git a/web/src/objects/objects-table.stories.tsx b/web/src/objects/objects-table.stories.tsx new file mode 100644 index 0000000..dc31bc9 --- /dev/null +++ b/web/src/objects/objects-table.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, userEvent, waitFor, within } from 'storybook/test' +import { http, HttpResponse } from 'msw' + +import { ObjectsTable } from './objects-table' + +const meta = { + component: ObjectsTable, + tags: ['ai-generated'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + play: async ({ canvas }) => { + await expect(await canvas.findByText('Amphora')).toBeVisible() + await expect(canvas.getByText('LM-0042')).toBeVisible() + }, +} + +// Clicking the Name header sorts ascending and the active header reports its +// aria-sort (the preview already provides the router; URL state lives there). +export const Sorted: Story = { + play: async ({ canvas }) => { + await canvas.findByText('Amphora') + const header = canvas.getByRole('columnheader', { name: /Name/i }) + await userEvent.click(within(header).getByRole('button')) + await waitFor(() => expect(header).toHaveAttribute('aria-sort', 'ascending')) + }, +} + +export const Empty: Story = { + parameters: { + msw: { + handlers: [ + http.get('/api/admin/objects', () => + HttpResponse.json({ items: [], total: 0, limit: 50, offset: 0 }), + ), + ], + }, + }, + play: async ({ canvas }) => { + await waitFor(() => + expect(within(canvas.getByRole('table')).getByText(/no objects yet/i)).toBeVisible(), + ) + }, +} diff --git a/web/src/objects/objects-table.test.tsx b/web/src/objects/objects-table.test.tsx new file mode 100644 index 0000000..1f3ea6e --- /dev/null +++ b/web/src/objects/objects-table.test.tsx @@ -0,0 +1,136 @@ +import { beforeEach, expect, test } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { Route, Routes } from "react-router-dom"; + +import { server } from "../test/server"; +import { renderApp } from "../test/render"; +import { objectsPage } from "../test/fixtures"; +import { ObjectsTable } from "./objects-table"; +import { ObjectDetail } from "./object-detail"; +import i18n from "../i18n"; + +beforeEach(async () => { + await i18n.changeLanguage("en"); +}); + +function tree() { + return ( + + } /> + } /> + + ); +} + +/** Capture the query string of every objects request so assertions can inspect URL → request flow. */ +function captureRequests() { + const calls: URLSearchParams[] = []; + + server.use( + http.get("/api/admin/objects", ({ request }) => { + calls.push(new URL(request.url).searchParams); + + return HttpResponse.json(objectsPage); + }), + ); + + return calls; +} + +function last(calls: URLSearchParams[]): URLSearchParams { + return calls[calls.length - 1]; +} + +test("renders a row per object with number, name, visibility, location and count", async () => { + renderApp(tree(), { route: "/objects" }); + + expect(await screen.findByText("Amphora")).toBeInTheDocument(); + expect(screen.getByText("LM-0042")).toBeInTheDocument(); + // "Public" is also a filter chip and the location is shared across fixtures; + // scope column assertions to the Amphora row. + const row = screen.getByText("Amphora").closest("tr")!; + expect(within(row).getByText("Public")).toBeInTheDocument(); + expect(within(row).getByText("Vault 3")).toBeInTheDocument(); +}); + +test("clicking a sortable header updates sort/order and aria-sort", async () => { + const calls = captureRequests(); + renderApp(tree(), { route: "/objects" }); + await screen.findByText("Amphora"); + + const nameHeader = screen.getByRole("columnheader", { name: /Name/i }); + expect(nameHeader).toHaveAttribute("aria-sort", "none"); + + await userEvent.click(within(nameHeader).getByRole("button")); + + await waitFor(() => expect(nameHeader).toHaveAttribute("aria-sort", "ascending")); + await waitFor(() => { + const cur = last(calls); + expect(cur.get("sort")).toBe("object_name"); + expect(cur.get("order")).toBe("asc"); + }); + + await userEvent.click(within(nameHeader).getByRole("button")); + await waitFor(() => expect(nameHeader).toHaveAttribute("aria-sort", "descending")); +}); + +test("typing in the quick filter sets q (debounced)", async () => { + const calls = captureRequests(); + renderApp(tree(), { route: "/objects" }); + await screen.findByText("Amphora"); + + await userEvent.type(screen.getByLabelText(/filter objects/i), "amph"); + + await waitFor(() => expect(last(calls).get("q")).toBe("amph")); +}); + +test("a visibility chip sets the visibility param", async () => { + const calls = captureRequests(); + renderApp(tree(), { route: "/objects" }); + await screen.findByText("Amphora"); + + await userEvent.click(screen.getByRole("button", { name: /^draft$/i })); + + await waitFor(() => expect(last(calls).get("visibility")).toBe("draft")); +}); + +test("pagination next/prev change the offset", async () => { + const calls = captureRequests(); + // total > limit so Next is enabled. + server.use( + http.get("/api/admin/objects", ({ request }) => { + calls.push(new URL(request.url).searchParams); + + return HttpResponse.json({ ...objectsPage, total: 200 }); + }), + ); + renderApp(tree(), { route: "/objects" }); + await screen.findByText("Amphora"); + + await userEvent.click(screen.getByRole("button", { name: /next/i })); + await waitFor(() => expect(last(calls).get("offset")).toBe("50")); + + // Back to the first page: the URL drops `offset`, so the request sends offset 0. + await userEvent.click(screen.getByRole("button", { name: /previous/i })); + await waitFor(() => expect(last(calls).get("offset")).toBe("0")); +}); + +test("the page-size select sets the limit", async () => { + const calls = captureRequests(); + renderApp(tree(), { route: "/objects" }); + await screen.findByText("Amphora"); + + await userEvent.selectOptions(screen.getByLabelText(/per page/i), "100"); + + await waitFor(() => expect(last(calls).get("limit")).toBe("100")); +}); + +test("clicking a row deep-links to /objects/:id preserving the query string", async () => { + renderApp(tree(), { route: "/objects?sort=object_name&order=desc" }); + + await userEvent.click(await screen.findByText("Amphora")); + + expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument(); +}); diff --git a/web/src/objects/objects-table.tsx b/web/src/objects/objects-table.tsx new file mode 100644 index 0000000..3fa378a --- /dev/null +++ b/web/src/objects/objects-table.tsx @@ -0,0 +1,324 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, ChevronUp, ChevronsUpDown } from "lucide-react"; + +import type { components } from "../api/schema"; +import { useObjectsPage } from "../api/queries"; +import { useDebouncedValue } from "../lib/use-debounced-value"; +import { useConfig } from "../config/config-context"; +import { VisibilityBadge } from "./visibility-badge"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; + +type AdminObjectView = components["schemas"]["AdminObjectView"]; + +const PAGE_SIZES = [25, 50, 100, 200]; +const VIS = ["all", "draft", "internal", "public"] as const; +const DEFAULT_SORT = "object_number"; +const DEFAULT_LIMIT = 50; + +type SortColumn = "object_number" | "object_name" | "updated_at"; + +const COLUMN_KEYS: Record = { + object_number: "objects.columns.number", + object_name: "objects.columns.name", + updated_at: "objects.columns.updated", +}; + +export function ObjectsTable() { + const { t, i18n } = useTranslation(); + const { default_timezone } = useConfig(); + const navigate = useNavigate(); + const { id: selectedId } = useParams(); + const [params, setParams] = useSearchParams(); + + const sort = params.get("sort") ?? DEFAULT_SORT; + const order = (params.get("order") === "desc" ? "desc" : "asc") as "asc" | "desc"; + const visibility = params.get("visibility") ?? "all"; + const limit = Number(params.get("limit")) || DEFAULT_LIMIT; + const offset = Number(params.get("offset")) || 0; + const qParam = params.get("q") ?? ""; + + const [qText, setQText] = useState(qParam); + const q = useDebouncedValue(qText, 300); + + // Mirror the search-panel pattern: sync the debounced quick-filter into the URL + // (setParams is router state, not component setState, so the lint rule allows it). + // Guard on the URL already matching `q` so re-renders caused by other URL updates + // (e.g. pagination changing `offset`) don't re-run this and clobber that state. + useEffect(() => { + const term = q.trim(); + + setParams( + (prev) => { + if ((prev.get("q") ?? "") === term) return prev; + + const next = new URLSearchParams(prev); + + if (term) next.set("q", term); + else next.delete("q"); + + next.delete("offset"); + + return next; + }, + { replace: true }, + ); + }, [q, setParams]); + + const { data, isLoading, isError } = useObjectsPage({ + limit, + offset, + sort, + order, + visibility: visibility === "all" ? undefined : visibility, + q: q.trim() || undefined, + }); + + const setParam = (mutate: (next: URLSearchParams) => void) => + setParams( + (prev) => { + const next = new URLSearchParams(prev); + mutate(next); + return next; + }, + { replace: true }, + ); + + const toggleSort = (col: SortColumn) => + setParam((next) => { + const curOrder = next.get("order") === "desc" ? "desc" : "asc"; + const curSort = next.get("sort") ?? DEFAULT_SORT; + const nextOrder = curSort === col && curOrder === "asc" ? "desc" : "asc"; + + next.set("sort", col); + next.set("order", nextOrder); + next.delete("offset"); + }); + + const setVisibility = (value: string) => + setParam((next) => { + if (value === "all") next.delete("visibility"); + else next.set("visibility", value); + + next.delete("offset"); + }); + + const setLimit = (value: number) => + setParam((next) => { + next.set("limit", String(value)); + next.delete("offset"); + }); + + const goToOffset = (value: number) => + setParam((next) => { + if (value <= 0) next.delete("offset"); + else next.set("offset", String(value)); + }); + + const dateFmt = new Intl.DateTimeFormat(i18n.language, { + dateStyle: "medium", + timeZone: default_timezone, + }); + const formatUpdated = (iso: string) => { + const parsed = new Date(iso); + + return Number.isNaN(parsed.getTime()) ? iso : dateFmt.format(parsed); + }; + + const headerCell = (col: SortColumn) => { + const active = sort === col; + const ariaSort = active ? (order === "asc" ? "ascending" : "descending") : "none"; + const Icon = !active ? ChevronsUpDown : order === "asc" ? ChevronUp : ChevronDown; + + return ( + + + + ); + }; + + const total = data?.total ?? 0; + const from = total === 0 ? 0 : offset + 1; + const to = Math.min(offset + limit, total); + + const toolbar = ( +
+ setQText(event.target.value)} + placeholder={t("objects.filter")} + aria-label={t("objects.filter")} + className="max-w-xs" + /> +
+ {VIS.map((value) => { + const active = visibility === value; + + return ( + + ); + })} +
+ + {t("objects.new")} + +
+ ); + + const columns = ( + + + {headerCell("object_number")} + {headerCell("object_name")} + + {t("objects.columns.visibility")} + + + {t("objects.columns.location")} + + + {t("objects.columns.count")} + + {headerCell("updated_at")} + + + ); + + let body; + + if (isLoading) { + body = ( + + {Array.from({ length: 8 }).map((_, i) => ( + + + + + + ))} + + ); + } else if (isError) { + body = ( + + + + {t("objects.loadError")} + + + + ); + } else if (!data || data.items.length === 0) { + body = ( + + + + {t("objects.empty")} + + + + ); + } else { + body = ( + + {data.items.map((item) => { + const object = item as AdminObjectView; + const selected = object.id === selectedId; + + return ( + navigate(`/objects/${object.id}?${params}`)} + onKeyDown={(event) => { + if (event.key === "Enter") navigate(`/objects/${object.id}?${params}`); + }} + className={`cursor-pointer border-b text-sm ${ + selected ? "bg-indigo-50" : "hover:bg-neutral-50" + }`} + > + {object.object_number} + {object.object_name} + + + + {object.current_location ?? "—"} + {object.number_of_objects} + {formatUpdated(object.updated_at)} + + ); + })} + + ); + } + + return ( +
+ {toolbar} +
+ + {columns} + {body} +
+
+
+ + + {from}–{to} {t("objects.of")} {total} + + + + + +
+
+ ); +} diff --git a/web/src/objects/select-prompt.tsx b/web/src/objects/select-prompt.tsx deleted file mode 100644 index 0cf2930..0000000 --- a/web/src/objects/select-prompt.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { useTranslation } from "react-i18next"; - -export function SelectPrompt() { - const { t } = useTranslation(); - - return ( -
- {t("objects.selectPrompt")} -
- ); -} diff --git a/web/src/shell/app-shell.tsx b/web/src/shell/app-shell.tsx index 43fb481..9262273 100644 --- a/web/src/shell/app-shell.tsx +++ b/web/src/shell/app-shell.tsx @@ -1,9 +1,10 @@ -import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { Outlet, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useLogout } from "../api/queries"; import { Button } from "@/components/ui/button"; import { LangSwitch } from "./lang-switch"; +import { Sidebar } from "./sidebar"; export function AppShell() { const { t } = useTranslation(); @@ -17,51 +18,7 @@ export function AppShell() { return (
- +
diff --git a/web/src/shell/sidebar.stories.tsx b/web/src/shell/sidebar.stories.tsx new file mode 100644 index 0000000..60070d6 --- /dev/null +++ b/web/src/shell/sidebar.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect } from 'storybook/test' + +import { Sidebar } from './sidebar' + +const meta = { + component: Sidebar, + tags: ['ai-generated'], +} satisfies Meta + +export default meta +type Story = StoryObj + +// Expanded is the default wide-viewport state: the stored preference is "not +// collapsed", so the nav labels render inline next to their icons. +export const Expanded: Story = { + beforeEach: () => { + localStorage.setItem('sidebar-collapsed', 'false') + }, + play: async ({ canvas }) => { + await expect(canvas.getByRole('link', { name: 'Objects' })).toBeVisible() + await expect(canvas.getByText('Vocabularies')).toBeVisible() + await expect( + canvas.getByRole('button', { name: 'Collapse sidebar' }), + ).toBeVisible() + }, +} + +// Collapsed rail: labels are no longer inline text — each link exposes its +// label only via `aria-label` (and a tooltip rendered in a portal). +export const Collapsed: Story = { + beforeEach: () => { + localStorage.setItem('sidebar-collapsed', 'true') + }, + play: async ({ canvas }) => { + const link = canvas.getByRole('link', { name: 'Objects' }) + await expect(link).toBeVisible() + await expect(link).toHaveAttribute('aria-label', 'Objects') + // No inline label text in the collapsed rail. + await expect(canvas.queryByText('Vocabularies')).not.toBeInTheDocument() + await expect( + canvas.getByRole('button', { name: 'Expand sidebar' }), + ).toBeVisible() + }, +} diff --git a/web/src/shell/sidebar.tsx b/web/src/shell/sidebar.tsx new file mode 100644 index 0000000..e395902 --- /dev/null +++ b/web/src/shell/sidebar.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from "react"; +import { NavLink } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { + BookMarked, + Boxes, + PanelLeft, + PanelLeftClose, + Search, + Tags, + Users, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Tooltip } from "@/components/ui/tooltip"; +import { useMediaQuery } from "@/lib/use-media-query"; + +const STORAGE_KEY = "sidebar-collapsed"; + +type NavItem = { + to: string; + /** i18n key under `nav.*`. */ + label: string; + Icon: LucideIcon; +}; + +const NAV_ITEMS: readonly NavItem[] = [ + { to: "/objects", label: "nav.objects", Icon: Boxes }, + { to: "/vocabularies", label: "nav.vocabularies", Icon: BookMarked }, + { to: "/authorities", label: "nav.authorities", Icon: Users }, + { to: "/search", label: "nav.search", Icon: Search }, + { to: "/fields", label: "nav.fields", Icon: Tags }, +]; + +function readStored(): boolean { + if (typeof window === "undefined") return false; + return window.localStorage.getItem(STORAGE_KEY) === "true"; +} + +function navLinkClass(collapsed: boolean) { + return ({ isActive }: { isActive: boolean }) => + cn( + "flex items-center gap-2 rounded px-2 py-1 outline-none", + "focus-visible:ring-3 focus-visible:ring-ring/50", + collapsed && "justify-center", + isActive && "bg-neutral-200 font-medium", + ); +} + +export function Sidebar() { + const { t } = useTranslation(); + const narrow = useMediaQuery("(max-width: 768px)"); + const [stored, setStored] = useState(readStored); + + // On narrow viewports the rail is always collapsed regardless of the stored + // preference; the toggle only takes effect when wide. + const collapsed = narrow || stored; + + useEffect(() => { + if (typeof window !== "undefined") { + window.localStorage.setItem(STORAGE_KEY, String(stored)); + } + }, [stored]); + + const toggle = () => setStored((prev) => !prev); + + return ( + + ); +} diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts index 3c34bc7..ce83c1f 100644 --- a/web/src/test/fixtures.ts +++ b/web/src/test/fixtures.ts @@ -14,6 +14,8 @@ export const amphora: AdminObjectView = { recorder: null, recording_date: null, visibility: "public", + created_at: "2026-01-02T10:00:00Z", + updated_at: "2026-01-05T14:30:00Z", fields: {}, }; diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 82707c8..6123a07 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -21,6 +21,25 @@ if (typeof globalThis.localStorage === "undefined") { }); } +// jsdom does not implement matchMedia. useMediaQuery (used by the shell +// sidebar) calls it on mount, so provide a minimal non-matching stub. +if (typeof window !== "undefined" && typeof window.matchMedia !== "function") { + Object.defineProperty(window, "matchMedia", { + value: (query: string): MediaQueryList => + ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }) as MediaQueryList, + writable: true, + }); +} + // Start MSW at module level so its fetch patch is in place before any test // module captures globalThis.fetch via openapi-fetch's createClient(). server.listen({ onUnhandledRequest: "error" });