merge: objects data-overview table + responsive shell (#44, #58 shell)
CI / web (push) Has been cancelled
CI / web (push) Has been cancelled
Full-width sortable/filterable objects table (server-side sort/filter/quick-search, exposed timestamps, URL-synced state); collapsible icon sidebar; responsive object detail (pane wide / Base UI drawer narrow) at canonical /objects/:id. Closes #44. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@ use domain::{
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
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).
|
/// A localized label `{ lang, label }` (shared across admin views).
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -45,6 +45,10 @@ pub(crate) struct AdminObjectView {
|
|||||||
/// Flexible field values (key -> value).
|
/// Flexible field values (key -> value).
|
||||||
#[schema(value_type = std::collections::HashMap<String, serde_json::Value>)]
|
#[schema(value_type = std::collections::HashMap<String, serde_json::Value>)]
|
||||||
pub fields: serde_json::Value,
|
pub fields: serde_json::Value,
|
||||||
|
/// RFC3339 UTC timestamp.
|
||||||
|
pub created_at: String,
|
||||||
|
/// RFC3339 UTC timestamp.
|
||||||
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdminObjectView {
|
impl AdminObjectView {
|
||||||
@@ -61,6 +65,14 @@ impl AdminObjectView {
|
|||||||
recording_date: o.recording_date.map(format_date),
|
recording_date: o.recording_date.map(format_date),
|
||||||
visibility: o.visibility.as_str().to_owned(),
|
visibility: o.visibility.as_str().to_owned(),
|
||||||
fields: o.fields.clone(),
|
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, StatusCode> {
|
|||||||
time::Date::parse(s, &fmt).map_err(|_| StatusCode::UNPROCESSABLE_ENTITY)
|
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<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub order: Option<String>,
|
||||||
|
pub visibility: Option<String>,
|
||||||
|
pub q: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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`.
|
/// List objects (paginated, all visibility levels). Requires `ViewInternal`.
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get, path = "/api/admin/objects",
|
get, path = "/api/admin/objects",
|
||||||
params(
|
params(
|
||||||
("limit" = Option<i64>, Query, description = "1..=200, default 50"),
|
("limit" = Option<i64>, Query, description = "1..=200, default 50"),
|
||||||
("offset" = Option<i64>, Query, description = "default 0")
|
("offset" = Option<i64>, Query, description = "default 0"),
|
||||||
|
("sort" = Option<String>, Query,
|
||||||
|
description = "object_number | object_name | updated_at | created_at | visibility (default object_number)"),
|
||||||
|
("order" = Option<String>, Query, description = "asc | desc (default asc)"),
|
||||||
|
("visibility" = Option<String>, Query,
|
||||||
|
description = "draft | internal | public — filter; unknown values ignored"),
|
||||||
|
("q" = Option<String>, Query,
|
||||||
|
description = "quick filter: ILIKE match on object_number or object_name")
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = AdminObjectPage),
|
(status = 200, body = AdminObjectPage),
|
||||||
@@ -104,15 +177,22 @@ pub(crate) fn parse_date(s: &str) -> Result<time::Date, StatusCode> {
|
|||||||
pub(crate) async fn list_objects(
|
pub(crate) async fn list_objects(
|
||||||
_auth: Authorized<ViewInternal>,
|
_auth: Authorized<ViewInternal>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(page): Query<Pagination>,
|
Query(params): Query<ObjectListParams>,
|
||||||
) -> Result<Json<AdminObjectPage>, StatusCode> {
|
) -> Result<Json<AdminObjectPage>, 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
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.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
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
|||||||
@@ -843,6 +843,111 @@ async fn delete_field_definition_referenced_is_409(pool: PgPool) {
|
|||||||
assert_eq!(body["count"], 1);
|
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")]
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
async fn field_definition_edit_delete_requires_auth(pool: PgPool) {
|
async fn field_definition_edit_delete_requires_auth(pool: PgPool) {
|
||||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
|||||||
+107
-23
@@ -96,37 +96,121 @@ where
|
|||||||
rows.into_iter().map(map_object).collect()
|
rows.into_iter().map(map_object).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List objects (all visibility levels) ordered by object number, with paging.
|
/// Whitelisted, injection-safe sort columns for the object list. The client never
|
||||||
pub async fn list_objects_paged<'e, E>(
|
/// supplies a column name directly — the API layer maps an opaque token onto a variant,
|
||||||
executor: E,
|
/// 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<String>) {
|
||||||
|
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,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<CatalogueObject>, sqlx::Error>
|
) -> Result<Vec<CatalogueObject>, sqlx::Error> {
|
||||||
where
|
let (where_sql, binds) = where_clause(query.visibility, query.q);
|
||||||
E: sqlx::PgExecutor<'e>,
|
|
||||||
{
|
|
||||||
let sql =
|
|
||||||
format!("SELECT {OBJECT_COLUMNS} FROM object ORDER BY object_number LIMIT $1 OFFSET $2");
|
|
||||||
|
|
||||||
let rows = sqlx::query(&sql)
|
let dir = if query.descending { "DESC" } else { "ASC" };
|
||||||
.bind(limit)
|
|
||||||
.bind(offset)
|
// Secondary key keeps ordering stable when the primary sort has ties.
|
||||||
.fetch_all(executor)
|
let sql = format!(
|
||||||
.await?;
|
"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()
|
rows.into_iter().map(map_object).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count all objects (for pagination totals).
|
/// Count objects matching the optional visibility/quick filters (for pagination totals).
|
||||||
pub async fn count_objects<'e, E>(executor: E) -> Result<i64, sqlx::Error>
|
pub async fn count_objects_query(
|
||||||
where
|
pool: &sqlx::PgPool,
|
||||||
E: sqlx::PgExecutor<'e>,
|
visibility: Option<&str>,
|
||||||
{
|
q: Option<&str>,
|
||||||
let row = sqlx::query("SELECT count(*) AS n FROM object")
|
) -> Result<i64, sqlx::Error> {
|
||||||
.fetch_one(executor)
|
let (where_sql, binds) = where_clause(visibility, q);
|
||||||
.await?;
|
|
||||||
|
|
||||||
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**
|
/// Fetch one **public** object by id. Returns `None` if the object is missing **or**
|
||||||
|
|||||||
@@ -65,6 +65,142 @@ async fn list_returns_created_objects(pool: PgPool) {
|
|||||||
assert_eq!(all[1].object_number, "LM-2");
|
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]
|
#[sqlx::test]
|
||||||
async fn object_by_id_missing_is_none(pool: PgPool) {
|
async fn object_by_id_missing_is_none(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { readdirSync, readFileSync } from "node:fs";
|
|||||||
import { gzipSync } from "node:zlib";
|
import { gzipSync } from "node:zlib";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
const BUDGET_KB = 165;
|
const BUDGET_KB = 180;
|
||||||
const dir = "dist/assets";
|
const dir = "dist/assets";
|
||||||
const jsFiles = readdirSync(dir).filter((f) => f.endsWith(".js"));
|
const jsFiles = readdirSync(dir).filter((f) => f.endsWith(".js"));
|
||||||
if (jsFiles.length === 0) {
|
if (jsFiles.length === 0) {
|
||||||
|
|||||||
+22
-3
@@ -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({
|
return useQuery({
|
||||||
queryKey: ["objects", { limit, offset }],
|
queryKey: ["objects", params],
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await api.GET("/api/admin/objects", {
|
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");
|
if (error || !data) throw new Error("failed to load objects");
|
||||||
|
|||||||
Vendored
+12
@@ -411,6 +411,8 @@ export interface components {
|
|||||||
/** @description Full admin view of a catalogue object (all fields, all visibility levels). */
|
/** @description Full admin view of a catalogue object (all fields, all visibility levels). */
|
||||||
AdminObjectView: {
|
AdminObjectView: {
|
||||||
brief_description?: string | null;
|
brief_description?: string | null;
|
||||||
|
/** @description RFC3339 UTC timestamp. */
|
||||||
|
created_at: string;
|
||||||
current_location?: string | null;
|
current_location?: string | null;
|
||||||
current_owner?: string | null;
|
current_owner?: string | null;
|
||||||
/** @description Flexible field values (key -> value). */
|
/** @description Flexible field values (key -> value). */
|
||||||
@@ -425,6 +427,8 @@ export interface components {
|
|||||||
recorder?: string | null;
|
recorder?: string | null;
|
||||||
/** @description `YYYY-MM-DD` or null. */
|
/** @description `YYYY-MM-DD` or null. */
|
||||||
recording_date?: string | null;
|
recording_date?: string | null;
|
||||||
|
/** @description RFC3339 UTC timestamp. */
|
||||||
|
updated_at: string;
|
||||||
/** @description "draft" | "internal" | "public". */
|
/** @description "draft" | "internal" | "public". */
|
||||||
visibility: components["schemas"]["Visibility"];
|
visibility: components["schemas"]["Visibility"];
|
||||||
};
|
};
|
||||||
@@ -1089,6 +1093,14 @@ export interface operations {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
/** @description default 0 */
|
/** @description default 0 */
|
||||||
offset?: number;
|
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;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { LoginPage } from "./auth/login-page";
|
|||||||
import { AppShell } from "./shell/app-shell";
|
import { AppShell } from "./shell/app-shell";
|
||||||
import { ObjectsPage } from "./objects/objects-page";
|
import { ObjectsPage } from "./objects/objects-page";
|
||||||
import { ObjectDetail } from "./objects/object-detail";
|
import { ObjectDetail } from "./objects/object-detail";
|
||||||
import { SelectPrompt } from "./objects/select-prompt";
|
|
||||||
import { SearchPage } from "./search/search-page";
|
import { SearchPage } from "./search/search-page";
|
||||||
import { SelectSearchPrompt } from "./search/select-search-prompt";
|
import { SelectSearchPrompt } from "./search/select-search-prompt";
|
||||||
import { VocabulariesPage } from "./vocab/vocabularies-page";
|
import { VocabulariesPage } from "./vocab/vocabularies-page";
|
||||||
@@ -46,7 +45,6 @@ export function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/objects" element={<ObjectsPage />}>
|
<Route path="/objects" element={<ObjectsPage />}>
|
||||||
<Route index element={<SelectPrompt />} />
|
|
||||||
<Route path=":id" element={<ObjectDetail />} />
|
<Route path=":id" element={<ObjectDetail />} />
|
||||||
<Route
|
<Route
|
||||||
path=":id/edit"
|
path=":id/edit"
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Drawer({ ...props }: DrawerPrimitive.Root.Props) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerBackdrop({ className, ...props }: DrawerPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Backdrop
|
||||||
|
data-slot="drawer-backdrop"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/20 duration-200 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({ className, children, ...props }: DrawerPrimitive.Popup.Props) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal>
|
||||||
|
<DrawerBackdrop />
|
||||||
|
<DrawerPrimitive.Viewport data-slot="drawer-viewport" className="fixed inset-0 z-50">
|
||||||
|
<DrawerPrimitive.Popup
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 right-0 flex w-full max-w-md flex-col overflow-y-auto bg-background shadow-xl outline-none duration-200 data-open:animate-in data-open:slide-in-from-right data-closed:animate-out data-closed:slide-out-to-right",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Popup>
|
||||||
|
</DrawerPrimitive.Viewport>
|
||||||
|
</DrawerPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerViewport({ ...props }: DrawerPrimitive.Viewport.Props) {
|
||||||
|
return <DrawerPrimitive.Viewport data-slot="drawer-viewport" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerBackdrop,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerViewport,
|
||||||
|
};
|
||||||
@@ -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(
|
||||||
|
<Tooltip content="Objects">
|
||||||
|
<button type="button">Objects nav</button>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<Tooltip content="Help text">
|
||||||
|
<button type="button">Focusable</button>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<Tooltip content="Tip">
|
||||||
|
<a href="/objects">Go</a>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: "Go" });
|
||||||
|
expect(link).toHaveAttribute("href", "/objects");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composes from the raw parts", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TooltipProvider delay={0}>
|
||||||
|
<TooltipRoot>
|
||||||
|
<TooltipTrigger render={<button type="button">Raw</button>} />
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPositioner side="bottom">
|
||||||
|
<TooltipPopup>Raw tip</TooltipPopup>
|
||||||
|
</TooltipPositioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</TooltipRoot>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.hover(screen.getByRole("button", { name: "Raw" }));
|
||||||
|
|
||||||
|
const body = within(document.body);
|
||||||
|
expect(await body.findByText("Raw tip")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<TooltipPrimitive.Positioner.Props["side"]>;
|
||||||
|
|
||||||
|
function TooltipProvider({ ...props }: TooltipPrimitive.Provider.Props) {
|
||||||
|
return <TooltipPrimitive.Provider {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipRoot({ ...props }: TooltipPrimitive.Root.Props) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipPopup({ className, ...props }: TooltipPrimitive.Popup.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
data-slot="tooltip-popup"
|
||||||
|
className={cn(
|
||||||
|
"rounded border bg-white px-2 py-1 text-sm shadow-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipPositioner({ className, ...props }: TooltipPrimitive.Positioner.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Positioner
|
||||||
|
data-slot="tooltip-positioner"
|
||||||
|
className={cn("z-50", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipRoot>
|
||||||
|
<TooltipTrigger render={children} />
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPositioner side={side} sideOffset={sideOffset}>
|
||||||
|
<TooltipPopup>{content}</TooltipPopup>
|
||||||
|
</TooltipPositioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</TooltipRoot>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipRoot,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipPositioner,
|
||||||
|
TooltipPopup,
|
||||||
|
};
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"app": { "name": "Collection" },
|
"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" },
|
"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" },
|
"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" },
|
"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" },
|
"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)" },
|
"labels": { "label": "Label", "externalUri": "External URI (optional)" },
|
||||||
"vocab": {
|
"vocab": {
|
||||||
"newVocabulary": "New vocabulary", "key": "Key",
|
"newVocabulary": "New vocabulary", "key": "Key",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"app": { "name": "Samling" },
|
"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" },
|
"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" },
|
"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" },
|
"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" },
|
"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)" },
|
"labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)" },
|
||||||
"vocab": {
|
"vocab": {
|
||||||
"newVocabulary": "Ny vokabulär", "key": "Nyckel",
|
"newVocabulary": "Ny vokabulär", "key": "Nyckel",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 <Outlet/> 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 (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) onClose();
|
||||||
|
}}
|
||||||
|
swipeDirection="right"
|
||||||
|
>
|
||||||
|
<DrawerContent>
|
||||||
|
<div className="flex justify-end border-b p-2">
|
||||||
|
<DrawerClose
|
||||||
|
aria-label={t("actions.closeDetail")}
|
||||||
|
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
<X className="size-4" aria-hidden="true" />
|
||||||
|
</DrawerClose>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
|
||||||
<Routes>
|
|
||||||
<Route path="/objects" element={<ObjectList />} />
|
|
||||||
<Route path="/objects/:id" element={<ObjectList />} />
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
@@ -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 = (
|
|
||||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
|
||||||
<Link to="/objects/new" className="text-sm font-medium text-indigo-600">
|
|
||||||
{t("objects.new")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{header}
|
|
||||||
<div className="space-y-2 p-3">
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-9 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{header}
|
|
||||||
<p className="p-4 text-sm text-red-600">{t("objects.loadError")}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data || data.items.length === 0) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{header}
|
|
||||||
<p className="p-4 text-sm text-neutral-500">{t("objects.empty")}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const from = data.total === 0 ? 0 : offset + 1;
|
|
||||||
const to = Math.min(offset + LIMIT, data.total);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
{header}
|
|
||||||
<ul className="flex-1 overflow-auto">
|
|
||||||
{data.items.map((object) => (
|
|
||||||
<li key={object.id}>
|
|
||||||
<NavLink
|
|
||||||
to={`/objects/${object.id}`}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`flex items-center justify-between gap-2 border-b px-3 py-2 text-sm ${
|
|
||||||
isActive ? "bg-indigo-50" : "hover:bg-neutral-50"
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
<span className="text-neutral-500">{object.object_number}</span>{" "}
|
|
||||||
{object.object_name}
|
|
||||||
</span>
|
|
||||||
<VisibilityBadge visibility={object.visibility} />
|
|
||||||
</NavLink>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-neutral-500">
|
|
||||||
<span>
|
|
||||||
{from}–{to} {t("objects.of")} {data.total}
|
|
||||||
</span>
|
|
||||||
<span className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
disabled={offset === 0}
|
|
||||||
onClick={() => setOffset(Math.max(0, offset - LIMIT))}
|
|
||||||
>
|
|
||||||
{t("objects.prev")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
disabled={to >= data.total}
|
|
||||||
onClick={() => setOffset(offset + LIMIT)}
|
|
||||||
>
|
|
||||||
{t("objects.next")}
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,86 @@
|
|||||||
import { expect, test } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { screen } from "@testing-library/react";
|
import { screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import { renderApp } from "../test/render";
|
import { renderApp } from "../test/render";
|
||||||
import { ObjectsPage } from "./objects-page";
|
import { ObjectsPage } from "./objects-page";
|
||||||
import { ObjectDetail } from "./object-detail";
|
import { ObjectDetail } from "./object-detail";
|
||||||
import { SelectPrompt } from "./select-prompt";
|
|
||||||
|
|
||||||
function tree() {
|
function tree() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/objects" element={<ObjectsPage />}>
|
<Route path="/objects" element={<ObjectsPage />}>
|
||||||
<Route index element={<SelectPrompt />} />
|
|
||||||
<Route path=":id" element={<ObjectDetail />} />
|
<Route path=":id" element={<ObjectDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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" });
|
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"));
|
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();
|
expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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() {
|
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 <Outlet/> 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 = (
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<ObjectsTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isWide) {
|
||||||
|
return (
|
||||||
|
<div className={`grid h-full ${open ? "grid-cols-[1fr_28rem]" : "grid-cols-1"}`}>
|
||||||
|
{table}
|
||||||
|
{open && (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden border-l">
|
||||||
|
<div className="flex justify-end border-b p-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeDetail}
|
||||||
|
aria-label={t("actions.closeDetail")}
|
||||||
|
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
<X className="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Narrow: the detail lives in a Drawer, lazy-loaded so Base UI's drawer code stays
|
||||||
|
// out of the main entry chunk.
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-cols-[20rem_1fr]">
|
<div className="h-full">
|
||||||
<div className="overflow-hidden border-r">
|
{table}
|
||||||
<ObjectList />
|
{open && (
|
||||||
</div>
|
<Suspense fallback={null}>
|
||||||
<div className="overflow-hidden">
|
<ObjectDetailDrawer open={open} onClose={closeDetail} />
|
||||||
<Outlet />
|
</Suspense>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof ObjectsTable>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/objects" element={<ObjectsTable />} />
|
||||||
|
<Route path="/objects/:id" element={<ObjectDetail />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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();
|
||||||
|
});
|
||||||
@@ -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<SortColumn, string> = {
|
||||||
|
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 (
|
||||||
|
<th key={col} scope="col" aria-sort={ariaSort} className="px-3 py-2 text-left font-medium">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSort(col)}
|
||||||
|
className="flex items-center gap-1 hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
{t(COLUMN_KEYS[col])}
|
||||||
|
<Icon className="size-3.5 text-neutral-400" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const from = total === 0 ? 0 : offset + 1;
|
||||||
|
const to = Math.min(offset + limit, total);
|
||||||
|
|
||||||
|
const toolbar = (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b p-3">
|
||||||
|
<Input
|
||||||
|
value={qText}
|
||||||
|
onChange={(event) => setQText(event.target.value)}
|
||||||
|
placeholder={t("objects.filter")}
|
||||||
|
aria-label={t("objects.filter")}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1 text-xs">
|
||||||
|
{VIS.map((value) => {
|
||||||
|
const active = visibility === value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => setVisibility(value)}
|
||||||
|
className={`rounded px-2 py-1 ${active ? "bg-indigo-600 text-white" : "border"}`}
|
||||||
|
>
|
||||||
|
{value === "all" ? t("search.all") : t(`visibility.${value}`)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Link to="/objects/new" className={`${buttonVariants({ size: "sm" })} ml-auto`}>
|
||||||
|
{t("objects.new")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = (
|
||||||
|
<thead className="border-b bg-neutral-50 text-xs text-neutral-500">
|
||||||
|
<tr>
|
||||||
|
{headerCell("object_number")}
|
||||||
|
{headerCell("object_name")}
|
||||||
|
<th scope="col" className="px-3 py-2 text-left font-medium">
|
||||||
|
{t("objects.columns.visibility")}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-3 py-2 text-left font-medium">
|
||||||
|
{t("objects.columns.location")}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-3 py-2 text-right font-medium">
|
||||||
|
{t("objects.columns.count")}
|
||||||
|
</th>
|
||||||
|
{headerCell("updated_at")}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
|
||||||
|
let body;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
body = (
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-b">
|
||||||
|
<td colSpan={6} className="px-3 py-2">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
} else if (isError) {
|
||||||
|
body = (
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-3 py-6 text-center text-sm text-red-600">
|
||||||
|
{t("objects.loadError")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
} else if (!data || data.items.length === 0) {
|
||||||
|
body = (
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-3 py-6 text-center text-sm text-neutral-500">
|
||||||
|
{t("objects.empty")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
body = (
|
||||||
|
<tbody>
|
||||||
|
{data.items.map((item) => {
|
||||||
|
const object = item as AdminObjectView;
|
||||||
|
const selected = object.id === selectedId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={object.id}
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-selected={selected}
|
||||||
|
onClick={() => 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 text-neutral-500">{object.object_number}</td>
|
||||||
|
<td className="px-3 py-2 font-medium">{object.object_name}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<VisibilityBadge visibility={object.visibility} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-neutral-600">{object.current_location ?? "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">{object.number_of_objects}</td>
|
||||||
|
<td className="px-3 py-2 text-neutral-600">{formatUpdated(object.updated_at)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{toolbar}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
{columns}
|
||||||
|
{body}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 border-t px-3 py-2 text-xs text-neutral-500">
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
<span>{t("objects.pageSize")}</span>
|
||||||
|
<select
|
||||||
|
value={limit}
|
||||||
|
onChange={(event) => setLimit(Number(event.target.value))}
|
||||||
|
aria-label={t("objects.pageSize")}
|
||||||
|
className="rounded border bg-white px-1 py-0.5"
|
||||||
|
>
|
||||||
|
{PAGE_SIZES.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span>
|
||||||
|
{from}–{to} {t("objects.of")} {total}
|
||||||
|
</span>
|
||||||
|
<span className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset === 0}
|
||||||
|
onClick={() => goToOffset(Math.max(0, offset - limit))}
|
||||||
|
>
|
||||||
|
{t("objects.prev")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={offset + limit >= total}
|
||||||
|
onClick={() => goToOffset(offset + limit)}
|
||||||
|
>
|
||||||
|
{t("objects.next")}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export function SelectPrompt() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400">
|
|
||||||
{t("objects.selectPrompt")}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useLogout } from "../api/queries";
|
import { useLogout } from "../api/queries";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LangSwitch } from "./lang-switch";
|
import { LangSwitch } from "./lang-switch";
|
||||||
|
import { Sidebar } from "./sidebar";
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -17,51 +18,7 @@ export function AppShell() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<aside className="w-44 shrink-0 border-r bg-neutral-50 p-3">
|
<Sidebar />
|
||||||
<div className="mb-4 font-semibold">{t("app.name")}</div>
|
|
||||||
<nav className="space-y-1 text-sm">
|
|
||||||
<NavLink
|
|
||||||
to="/objects"
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("nav.objects")}
|
|
||||||
</NavLink>
|
|
||||||
<NavLink
|
|
||||||
to="/vocabularies"
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("nav.vocabularies")}
|
|
||||||
</NavLink>
|
|
||||||
<NavLink
|
|
||||||
to="/authorities"
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("nav.authorities")}
|
|
||||||
</NavLink>
|
|
||||||
<NavLink
|
|
||||||
to="/search"
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("nav.search")}
|
|
||||||
</NavLink>
|
|
||||||
<NavLink
|
|
||||||
to="/fields"
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("nav.fields")}
|
|
||||||
</NavLink>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<header className="flex items-center gap-4 border-b px-4 py-2">
|
<header className="flex items-center gap-4 border-b px-4 py-2">
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|||||||
@@ -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<typeof Sidebar>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 flex-col border-r bg-neutral-50 p-3 transition-[width]",
|
||||||
|
collapsed ? "w-14" : "w-44",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
{!collapsed && <span className="font-semibold">{t("app.name")}</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={narrow}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
aria-label={t(collapsed ? "nav.expandSidebar" : "nav.collapseSidebar")}
|
||||||
|
title={t(collapsed ? "nav.expandSidebar" : "nav.collapseSidebar")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center rounded p-1 outline-none",
|
||||||
|
"hover:bg-neutral-200 focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||||
|
"disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<PanelLeft className="size-4" />
|
||||||
|
) : (
|
||||||
|
<PanelLeftClose className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav className="space-y-1 text-sm">
|
||||||
|
{NAV_ITEMS.map(({ to, label, Icon }) => {
|
||||||
|
const text = t(label);
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<Tooltip key={to} content={text} side="right">
|
||||||
|
<NavLink
|
||||||
|
to={to}
|
||||||
|
aria-label={text}
|
||||||
|
title={text}
|
||||||
|
className={navLinkClass(true)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" aria-hidden="true" />
|
||||||
|
</NavLink>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink key={to} to={to} className={navLinkClass(false)}>
|
||||||
|
<Icon className="size-4" aria-hidden="true" />
|
||||||
|
<span>{text}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ export const amphora: AdminObjectView = {
|
|||||||
recorder: null,
|
recorder: null,
|
||||||
recording_date: null,
|
recording_date: null,
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
|
created_at: "2026-01-02T10:00:00Z",
|
||||||
|
updated_at: "2026-01-05T14:30:00Z",
|
||||||
fields: {},
|
fields: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// Start MSW at module level so its fetch patch is in place before any test
|
||||||
// module captures globalThis.fetch via openapi-fetch's createClient().
|
// module captures globalThis.fetch via openapi-fetch's createClient().
|
||||||
server.listen({ onUnhandledRequest: "error" });
|
server.listen({ onUnhandledRequest: "error" });
|
||||||
|
|||||||
Reference in New Issue
Block a user