From 5efa7b8a16267e340560e228ab5393e54c07dc35 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sat, 6 Jun 2026 23:17:50 +0200 Subject: [PATCH 01/10] feat(api): expose object created_at/updated_at in AdminObjectView (#44) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/api/src/admin_objects.rs | 12 +++++++++++ crates/api/tests/admin_catalog.rs | 34 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/crates/api/src/admin_objects.rs b/crates/api/src/admin_objects.rs index ef32f2d..6664dbf 100644 --- a/crates/api/src/admin_objects.rs +++ b/crates/api/src/admin_objects.rs @@ -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(), } } } diff --git a/crates/api/tests/admin_catalog.rs b/crates/api/tests/admin_catalog.rs index a8d957c..0c998ae 100644 --- a/crates/api/tests/admin_catalog.rs +++ b/crates/api/tests/admin_catalog.rs @@ -843,6 +843,40 @@ 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 field_definition_edit_delete_requires_auth(pool: PgPool) { migrate_sessions(&db::Db::from_pool(pool.clone())) From 60a1b8dccf3afa8606ba1ba02f147cdb45270b06 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sat, 6 Jun 2026 23:21:04 +0200 Subject: [PATCH 02/10] feat: object list sort/filter/quick-search (server-side, injection-safe) (#44) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/api/src/admin_objects.rs | 80 ++++++++++++++++-- crates/api/tests/admin_catalog.rs | 71 ++++++++++++++++ crates/db/src/catalog.rs | 130 +++++++++++++++++++++++----- crates/db/tests/catalog.rs | 136 ++++++++++++++++++++++++++++++ 4 files changed, 388 insertions(+), 29 deletions(-) diff --git a/crates/api/src/admin_objects.rs b/crates/api/src/admin_objects.rs index 6664dbf..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)] @@ -100,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), @@ -116,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 0c998ae..dc0e589 100644 --- a/crates/api/tests/admin_catalog.rs +++ b/crates/api/tests/admin_catalog.rs @@ -877,6 +877,77 @@ async fn listed_object_carries_timestamps(pool: PgPool) { 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); From 98c00d3732cd1941d53eeeb557db01229ef53461 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sat, 6 Jun 2026 23:24:17 +0200 Subject: [PATCH 03/10] chore(web): regenerate API types (object list params + timestamps) (#44) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/api/schema.d.ts | 12 ++++++++++++ web/src/test/fixtures.ts | 2 ++ 2 files changed, 14 insertions(+) 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/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: {}, }; From 49f694d1fb8791119deb847d3b48f079b5d53780 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sat, 6 Jun 2026 23:34:13 +0200 Subject: [PATCH 04/10] feat(web): full-width sortable/filterable objects table with URL state (#44) Replace the narrow ObjectList with a full-width ObjectsTable whose state (sort/order/q/visibility/limit/offset) lives entirely in the URL via useSearchParams. Sortable headers toggle sort+dir with aria-sort, a debounced quick-filter and visibility chips mirror the search-panel pattern, and a pagination footer offers prev/next + page-size select. Rows deep-link to /objects/:id preserving the query string. useObjectsPage now takes an ObjectListParams object (sort/order/ visibility/q) with keepPreviousData. ObjectsPage renders the table as the full-width landing view, surfacing the nested detail as a simple right-side panel only when a :id child route is active (Phase 3 makes this responsive). object-list.tsx and its test are removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/api/queries.ts | 25 +- web/src/i18n/en.json | 2 +- web/src/i18n/sv.json | 2 +- web/src/objects/object-list.test.tsx | 46 ---- web/src/objects/object-list.tsx | 108 -------- web/src/objects/objects-page.test.tsx | 13 +- web/src/objects/objects-page.tsx | 23 +- web/src/objects/objects-table.stories.tsx | 48 ++++ web/src/objects/objects-table.test.tsx | 136 +++++++++ web/src/objects/objects-table.tsx | 319 ++++++++++++++++++++++ 10 files changed, 553 insertions(+), 169 deletions(-) delete mode 100644 web/src/objects/object-list.test.tsx delete mode 100644 web/src/objects/object-list.tsx create mode 100644 web/src/objects/objects-table.stories.tsx create mode 100644 web/src/objects/objects-table.test.tsx create mode 100644 web/src/objects/objects-table.tsx 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/i18n/en.json b/web/src/i18n/en.json index 2ed788b..b51343e 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -2,7 +2,7 @@ "app": { "name": "Collection" }, "nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search" }, "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", "selectPrompt": "Select an object to view its details", "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" }, diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 456d269..feaaa52 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -2,7 +2,7 @@ "app": { "name": "Samling" }, "nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök" }, "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", "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", "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" }, 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..0ad92bc 100644 --- a/web/src/objects/objects-page.test.tsx +++ b/web/src/objects/objects-page.test.tsx @@ -18,10 +18,17 @@ function tree() { ); } -test("selecting a row shows its detail in the right pane", async () => { +test("the table is the landing view; the detail prompt is not a fixed column", async () => { + renderApp(tree(), { route: "/objects" }); + + // Table rows render full-width; no detail panel (and thus no prompt) until a row is opened. + expect(await screen.findByText("Amphora")).toBeInTheDocument(); + expect(screen.queryByText(/select an object/i)).not.toBeInTheDocument(); +}); + +test("clicking a row opens its detail in the side panel", 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 index 10d5b67..ff28b48 100644 --- a/web/src/objects/objects-page.tsx +++ b/web/src/objects/objects-page.tsx @@ -1,16 +1,25 @@ -import { Outlet } from "react-router-dom"; +import { Outlet, useMatch } from "react-router-dom"; -import { ObjectList } from "./object-list"; +import { ObjectsTable } from "./objects-table"; export function ObjectsPage() { + // Interim layout (Phase 3 makes this a responsive pane/drawer): the table is the + // full-width landing view; when a `:id`/`:id/edit` child route is active we render + // the nested as a simple right-side panel. + const detailMatch = useMatch("/objects/:id"); + const editMatch = useMatch("/objects/:id/edit"); + const detail = detailMatch ?? editMatch; + return ( -
-
- -
+
- +
+ {detail && ( +
+ +
+ )}
); } 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..4da6892 --- /dev/null +++ b/web/src/objects/objects-table.tsx @@ -0,0 +1,319 @@ +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}`)} + 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} + + + + + +
+
+ ); +} From 04c33cb1aae03014004df65df5d0bec5b9b6bc7f Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sat, 6 Jun 2026 23:40:19 +0200 Subject: [PATCH 05/10] feat(web): useMediaQuery hook + Base UI tooltip wrapper (#44) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/components/ui/tooltip.test.tsx | 83 ++++++++++++++++++++++++++ web/src/components/ui/tooltip.tsx | 82 +++++++++++++++++++++++++ web/src/lib/use-media-query.ts | 20 +++++++ 3 files changed, 185 insertions(+) create mode 100644 web/src/components/ui/tooltip.test.tsx create mode 100644 web/src/components/ui/tooltip.tsx create mode 100644 web/src/lib/use-media-query.ts 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/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; +} From 184e4ea2a5fd32f3e8e0f8bcfa5ad2d8be9af9cd Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sat, 6 Jun 2026 23:44:40 +0200 Subject: [PATCH 06/10] feat(web): collapsible icon sidebar (persisted, auto-collapse on narrow) (#44, #58) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/i18n/en.json | 2 +- web/src/i18n/sv.json | 2 +- web/src/shell/app-shell.tsx | 49 +----------- web/src/shell/sidebar.stories.tsx | 45 +++++++++++ web/src/shell/sidebar.tsx | 126 ++++++++++++++++++++++++++++++ web/src/test/setup.ts | 19 +++++ 6 files changed, 195 insertions(+), 48 deletions(-) create mode 100644 web/src/shell/sidebar.stories.tsx create mode 100644 web/src/shell/sidebar.tsx diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index b51343e..d08b7e6 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -1,6 +1,6 @@ { "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", "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" }, diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index feaaa52..e8cab48 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -1,6 +1,6 @@ { "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", "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" }, 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/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" }); From b8f70212a1ba38042f7eff40bdb3d9363b10855c Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sat, 6 Jun 2026 23:53:10 +0200 Subject: [PATCH 07/10] feat(web): responsive object detail (pane/drawer) at canonical /objects/:id (#44, #58) Wide (>=1024px): right-hand pane beside the table with a close control. Narrow: Base UI Drawer sliding from the right (lazy-loaded so its code splits out of the main chunk). Both preserve the table's query string on close. Remove the index SelectPrompt route (the table is the landing view) and delete the now-unused SelectPrompt. Make table rows keyboard-activatable (role=link, tabIndex, Enter). Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/app.tsx | 2 - web/src/components/ui/drawer.tsx | 66 ++++++++++++++++++++++ web/src/i18n/en.json | 2 +- web/src/i18n/sv.json | 2 +- web/src/objects/object-detail-drawer.tsx | 44 +++++++++++++++ web/src/objects/objects-page.test.tsx | 68 ++++++++++++++++++---- web/src/objects/objects-page.tsx | 72 +++++++++++++++++++----- web/src/objects/objects-table.tsx | 5 ++ web/src/objects/select-prompt.tsx | 11 ---- 9 files changed, 234 insertions(+), 38 deletions(-) create mode 100644 web/src/components/ui/drawer.tsx create mode 100644 web/src/objects/object-detail-drawer.tsx delete mode 100644 web/src/objects/select-prompt.tsx 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/i18n/en.json b/web/src/i18n/en.json index d08b7e6..3d0a309 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -6,7 +6,7 @@ "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 e8cab48..e170faa 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -6,7 +6,7 @@ "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/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/objects-page.test.tsx b/web/src/objects/objects-page.test.tsx index 0ad92bc..d753d2b 100644 --- a/web/src/objects/objects-page.test.tsx +++ b/web/src/objects/objects-page.test.tsx @@ -1,34 +1,82 @@ -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("the table is the landing view; the detail prompt is not a fixed column", async () => { - renderApp(tree(), { route: "/objects" }); +// 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, + }); +} - // Table rows render full-width; no detail panel (and thus no prompt) until a row is opened. - expect(await screen.findByText("Amphora")).toBeInTheDocument(); - expect(screen.queryByText(/select an object/i)).not.toBeInTheDocument(); +afterEach(() => { + vi.restoreAllMocks(); }); -test("clicking a row opens its detail in the side panel", async () => { +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" }); 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. + const body = within(document.body); + expect(await body.findByRole("heading", { name: "Amphora" })).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 ff28b48..bc5c777 100644 --- a/web/src/objects/objects-page.tsx +++ b/web/src/objects/objects-page.tsx @@ -1,24 +1,70 @@ -import { Outlet, useMatch } 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 { 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() { - // Interim layout (Phase 3 makes this a responsive pane/drawer): the table is the - // full-width landing view; when a `:id`/`:id/edit` child route is active we render - // the nested as a simple right-side panel. + 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 detail = detailMatch ?? editMatch; + const open = Boolean(detailMatch ?? editMatch); + const isWide = useMediaQuery("(min-width: 1024px)"); - return ( -
-
- + const closeDetail = () => navigate(`/objects?${searchParams}`); + + const table = ( +
+ +
+ ); + + if (isWide) { + return ( +
+ {table} + {open && ( +
+
+ +
+
+ +
+
+ )}
- {detail && ( -
- -
+ ); + } + + // 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.tsx b/web/src/objects/objects-table.tsx index 4da6892..3fa378a 100644 --- a/web/src/objects/objects-table.tsx +++ b/web/src/objects/objects-table.tsx @@ -246,8 +246,13 @@ export function ObjectsTable() { 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" }`} 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")} -
- ); -} From e7b0f65686fa95658976c9bf96218d967ab7b2f2 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sat, 6 Jun 2026 23:58:03 +0200 Subject: [PATCH 08/10] =?UTF-8?q?chore(web):=20raise=20bundle=20budget=201?= =?UTF-8?q?65=E2=86=92180=20KB=20gz=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collapsed-sidebar tooltips use Base UI's Tooltip, which pulls floating-ui into the always-loaded shell chunk. Kept the richer tooltip over native title; the index is a feature-rich admin SPA bundle. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/scripts/check-bundle-size.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) { From c052ddc5af2c1adb695cc7050b3bb4219eaa7f48 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 00:03:46 +0200 Subject: [PATCH 09/10] test(web): widen findBy timeout for the lazy/portaled narrow-drawer detail test (#44) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/objects/objects-page.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web/src/objects/objects-page.test.tsx b/web/src/objects/objects-page.test.tsx index d753d2b..0b92a05 100644 --- a/web/src/objects/objects-page.test.tsx +++ b/web/src/objects/objects-page.test.tsx @@ -67,9 +67,13 @@ test("narrow: detail renders inside a portaled drawer", async () => { renderApp(tree(), { route: "/objects" }); await userEvent.click(await screen.findByText("Amphora")); - // The drawer popup is portaled to document.body. + // 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" })).toBeInTheDocument(); + expect( + await body.findByRole("heading", { name: "Amphora" }, { timeout: 5000 }), + ).toBeInTheDocument(); expect(body.getByRole("button", { name: /close detail/i })).toBeInTheDocument(); }); From 6a62cf64bf97e2ac2666919557fc8da5ca36eeb7 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 00:13:32 +0200 Subject: [PATCH 10/10] chore(web): drop dead objects.selectPrompt i18n key (#44) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/i18n/en.json | 2 +- web/src/i18n/sv.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 3d0a309..607e2da 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -2,7 +2,7 @@ "app": { "name": "Collection" }, "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", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" } }, + "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" }, diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index e170faa..f309267 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -2,7 +2,7 @@ "app": { "name": "Samling" }, "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", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" } }, + "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" },