Compare commits
13 Commits
b49699175d
...
0a88a86bb3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a88a86bb3 | |||
| 6a62cf64bf | |||
| c052ddc5af | |||
| e7b0f65686 | |||
| b8f70212a1 | |||
| 184e4ea2a5 | |||
| 04c33cb1aa | |||
| 49f694d1fb | |||
| 98c00d3732 | |||
| 60a1b8dccf | |||
| 5efa7b8a16 | |||
| e7ff817c63 | |||
| fb80146430 |
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
# Objects Data-Overview Table + Responsive Shell — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Turn `/objects` into a full-width, sortable, filterable data table (backed by Postgres sort/filter + exposed timestamps), with a collapsible icon sidebar and a responsive detail pane/drawer at a canonical `/objects/:id` URL.
|
||||||
|
|
||||||
|
**Architecture:** Phase 1 adds backend `sort`/`order`/`visibility`/`q` params (injection-safe) + a filtered count + exposes `created_at`/`updated_at`. Phase 2 replaces the narrow `ObjectList` with a full-width `ObjectsTable` whose state lives in the URL. Phase 3 makes the shell sidebar collapsible (lucide icons + Base UI tooltip) and renders detail as a right pane (wide) / Base UI `Drawer` (narrow) via the existing nested `/objects/:id` route.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (axum, sqlx/Postgres, utoipa), React 19 + TS + pnpm, `@base-ui/react` (drawer/collapsible/tooltip — already a dep), `lucide-react` 1.17 (already a dep), react-router 7, TanStack Query, Vitest+RTL+MSW, Storybook 10.
|
||||||
|
|
||||||
|
**Conventions:** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets -- -D warnings`; tests via `cargo nextest run`; pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; component source double-quote+semicolon, stories single-quote+no-semicolon; en/sv parity for new keys; **no codename**; portal queries in tests via `within(document.body)`; `pnpm check:size` budget **165 KB gz**. Test infra: Postgres 5442, Meili 7700; `#[sqlx::test(migrations="../db/migrations")]`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-06-objects-table-and-shell-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
**Backend:** `crates/db/src/catalog.rs` (filtered list+count, sort enum), `crates/api/src/admin_objects.rs` (query params, `AdminObjectView` timestamps), `crates/api/src/openapi.rs` (if new schema types). **Frontend:** `web/src/api/queries.ts` (`useObjectsPage` params), new `web/src/objects/objects-table.tsx` (+ `.stories.tsx`, `.test.tsx`), `web/src/objects/objects-page.tsx` (restructure to table + responsive detail), `web/src/shell/app-shell.tsx` (collapsible sidebar), new `web/src/components/ui/tooltip.tsx`, new `web/src/lib/use-media-query.ts`, `web/src/i18n/{en,sv}.json`. `web/src/objects/object-list.tsx` is removed (replaced by the table).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# PHASE 1 — Backend
|
||||||
|
|
||||||
|
## Task 1: Expose `created_at` / `updated_at` on `AdminObjectView`
|
||||||
|
**Files:** `crates/api/src/admin_objects.rs`; test `crates/api/tests/admin_catalog.rs`.
|
||||||
|
|
||||||
|
The domain `CatalogueObject` already carries `created_at`/`updated_at` (`time::OffsetDateTime`); only the API view omits them. No migration.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing API test** in `admin_catalog.rs`: create an object, `GET /api/admin/objects`, assert the item has non-empty `created_at` and `updated_at` (RFC3339 strings). Run → fails (fields absent).
|
||||||
|
- [ ] **Step 2: Add fields.** In `AdminObjectView` add:
|
||||||
|
```rust
|
||||||
|
/// RFC3339 UTC timestamp.
|
||||||
|
pub created_at: String,
|
||||||
|
/// RFC3339 UTC timestamp.
|
||||||
|
pub updated_at: String,
|
||||||
|
```
|
||||||
|
In `from_object`, map them (the file already has a `format_date` for the `DATE`; for timestamps use RFC3339):
|
||||||
|
```rust
|
||||||
|
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(),
|
||||||
|
```
|
||||||
|
(Confirm `time` is a dep of the `api` crate; it is used transitively — if not in `Cargo.toml`, add `time.workspace = true`. Verify the `CatalogueObject` field names `created_at`/`updated_at` and their `OffsetDateTime` type in `crates/db/src/catalog.rs:210-211`.)
|
||||||
|
- [ ] **Step 3:** `cargo +nightly fmt`; `cargo clippy -p api`; run the test (compose up):
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo nextest run -p api -E 'test(admin_catalog)'
|
||||||
|
```
|
||||||
|
- [ ] **Step 4: Commit** `feat(api): expose object created_at/updated_at in AdminObjectView (#44)`.
|
||||||
|
|
||||||
|
## Task 2: Server-side sort / order / visibility / quick-filter for the object list
|
||||||
|
**Files:** `crates/db/src/catalog.rs`, `crates/api/src/admin_objects.rs`; tests in `crates/db/tests/object.rs` (or wherever catalog list is tested) + `crates/api/tests/admin_catalog.rs`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Define a sort enum + filtered db functions** in `crates/db/src/catalog.rs`. Add:
|
||||||
|
```rust
|
||||||
|
/// Whitelisted, injection-safe sort columns for the object list.
|
||||||
|
#[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.
|
||||||
|
pub struct ObjectQuery<'a> {
|
||||||
|
pub sort: ObjectSort,
|
||||||
|
pub descending: bool,
|
||||||
|
pub visibility: Option<&'a str>,
|
||||||
|
pub q: Option<&'a str>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Add `list_objects_query` + `count_objects_query` that build SQL from the **enum** (never a raw client string). Both share a WHERE builder. Example:
|
||||||
|
```rust
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_objects_query(
|
||||||
|
pool: &sqlx::PgPool, query: &ObjectQuery<'_>, limit: i64, offset: i64,
|
||||||
|
) -> Result<Vec<CatalogueObject>, sqlx::Error> {
|
||||||
|
let (where_sql, binds) = where_clause(query.visibility, query.q);
|
||||||
|
let dir = if query.descending { "DESC" } else { "ASC" };
|
||||||
|
// Secondary key keeps ordering stable when the primary sort has ties.
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT {OBJECT_COLUMNS} FROM object{where_sql} ORDER BY {} {dir}, object_number ASC LIMIT ${} OFFSET ${}",
|
||||||
|
query.sort.column(), binds.len() + 1, binds.len() + 2,
|
||||||
|
);
|
||||||
|
let mut q = sqlx::query(&sql);
|
||||||
|
for b in &binds { q = q.bind(b); }
|
||||||
|
let rows = q.bind(limit).bind(offset).fetch_all(pool).await?;
|
||||||
|
rows.into_iter().map(map_object).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn count_objects_query(
|
||||||
|
pool: &sqlx::PgPool, visibility: Option<&str>, q: Option<&str>,
|
||||||
|
) -> Result<i64, sqlx::Error> {
|
||||||
|
let (where_sql, binds) = where_clause(visibility, q);
|
||||||
|
let sql = format!("SELECT count(*) AS n FROM object{where_sql}");
|
||||||
|
let mut query = sqlx::query(&sql);
|
||||||
|
for b in &binds { query = query.bind(b); }
|
||||||
|
query.fetch_one(pool).await?.try_get("n")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Keep the existing `list_objects_paged`/`count_objects` if other callers use them (grep; if only the handler calls them, you may replace — verify). The `ObjectColumns`/`map_object` already include the timestamp columns.
|
||||||
|
- [ ] **Step 2: db tests** in the catalog test file: seed objects with distinct names/visibilities; assert `list_objects_query` orders by `object_name DESC`, filters by `visibility="draft"`, and `q` ILIKE matches number/name; `count_objects_query` returns the filtered count.
|
||||||
|
- [ ] **Step 3: Handler query params.** In `admin_objects.rs`, add a deserialize struct (don't overload the shared `Pagination`):
|
||||||
|
```rust
|
||||||
|
#[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>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Parse `sort` → `ObjectSort` (unknown → default `ObjectNumber`), `order` → `descending = order == "desc"`, clamp limit (1..=200, default 50) / offset (>=0) like `Pagination`. Validate `visibility` against `domain::Visibility` (unknown → 422 or ignore — pick ignore-with-default for resilience to hand-edited URLs). Build `ObjectQuery`, call `list_objects_query` + `count_objects_query`. Update the `#[utoipa::path]` `params(...)` to document `sort`/`order`/`visibility`/`q`.
|
||||||
|
- [ ] **Step 4: API test** — `GET /api/admin/objects?sort=object_name&order=desc&visibility=draft&q=foo` returns filtered+sorted items and a matching `total`; no params → unchanged default (object_number asc).
|
||||||
|
- [ ] **Step 5:** fmt + clippy + `cargo nextest run -p api -p db`. **Commit** `feat: object list sort/filter/quick-search (server-side, injection-safe) (#44)`.
|
||||||
|
|
||||||
|
## Task 3: Regenerate web API types
|
||||||
|
- [ ] Start the built server on an alt port (8080 may be taken): `BIND_ADDR=127.0.0.1:8090 DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… ./target/debug/server`, then `cd web && pnpm exec openapi-typescript http://localhost:8090/api-docs/openapi.json -o src/api/schema.d.ts`. Verify `created_at`/`updated_at` appear on `AdminObjectView`; `pnpm typecheck`. Stop the server. **Commit** `chore(web): regenerate API types (object list params + timestamps)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# PHASE 2 — The table
|
||||||
|
|
||||||
|
## Task 4: `useObjectsPage` gains sort/filter params
|
||||||
|
**Files:** `web/src/api/queries.ts`.
|
||||||
|
- [ ] Replace the `(limit, offset)` signature with a params object and `keepPreviousData`:
|
||||||
|
```ts
|
||||||
|
import { keepPreviousData } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export type ObjectListParams = {
|
||||||
|
limit: number; offset: number;
|
||||||
|
sort?: string; order?: "asc" | "desc";
|
||||||
|
visibility?: string; q?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useObjectsPage(params: ObjectListParams) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["objects", params],
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await api.GET("/api/admin/objects", {
|
||||||
|
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");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(openapi-fetch drops `undefined` query params, so omit-by-undefined is fine.) Update the existing call site in `object-list.tsx` — but that file is being replaced in Task 5; if Task 5 lands in the same branch, just ensure typecheck passes after Task 5. **Commit with Task 5** (or standalone if you prefer). Keep `useObject` unchanged.
|
||||||
|
|
||||||
|
## Task 5: `ObjectsTable` — full-width table, URL-synced state, pagination, sort headers
|
||||||
|
**Files:** create `web/src/objects/objects-table.tsx`, `objects-table.stories.tsx`, `objects-table.test.tsx`; delete `web/src/objects/object-list.tsx`.
|
||||||
|
|
||||||
|
Behavior: reads all state from the URL (`useSearchParams`) — `sort`, `order`, `q`, `visibility`, `offset`, `limit` (default sort `object_number`/`asc`, limit 50, offset 0). Renders a real `<table>`; reuses `VisibilityBadge`; columns № / Name / Visibility / Location / # / Updated; sortable headers toggle sort+dir (with `aria-sort`); a row is a `<tr>` whose click navigates to `/objects/:id` **preserving the current search string** (so back restores state); pagination footer with prev/next + page-size `<select>` (or the future `ui/select`); a debounced quick-filter `Input` (`q`) and visibility chips live in a toolbar (Task 6 may own the toolbar — implement them here together to keep the table coherent).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Component.** Concrete core (fill routine markup/classes to match the app; use token classes per #49 where easy, else existing patterns):
|
||||||
|
```tsx
|
||||||
|
import { useSearchParams, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { useObjectsPage } from "../api/queries";
|
||||||
|
import { useDebouncedValue } from "../lib/use-debounced-value";
|
||||||
|
import { VisibilityBadge } from "./visibility-badge";
|
||||||
|
// + ui/button, ui/input, ui/skeleton, lucide chevrons
|
||||||
|
|
||||||
|
const SORTABLE = ["object_number", "object_name", "updated_at"] as const;
|
||||||
|
const PAGE_SIZES = [25, 50, 100, 200];
|
||||||
|
const VIS = ["all", "draft", "internal", "public"] as const;
|
||||||
|
|
||||||
|
export function ObjectsTable() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id: selectedId } = useParams(); // highlight the open row
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
|
||||||
|
const sort = params.get("sort") ?? "object_number";
|
||||||
|
const order = (params.get("order") === "desc" ? "desc" : "asc") as "asc" | "desc";
|
||||||
|
const visibility = params.get("visibility") ?? "all";
|
||||||
|
const limit = Number(params.get("limit")) || 50;
|
||||||
|
const offset = Number(params.get("offset")) || 0;
|
||||||
|
const qParam = params.get("q") ?? "";
|
||||||
|
const [qText, setQText] = useState(qParam);
|
||||||
|
const q = useDebouncedValue(qText, 300);
|
||||||
|
|
||||||
|
// sync debounced q → URL (reset offset)
|
||||||
|
useEffect(() => {
|
||||||
|
setParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
const term = q.trim();
|
||||||
|
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: (n: URLSearchParams) => void) =>
|
||||||
|
setParams((prev) => { const n = new URLSearchParams(prev); mutate(n); return n; }, { replace: true });
|
||||||
|
|
||||||
|
const toggleSort = (col: string) =>
|
||||||
|
setParam((n) => {
|
||||||
|
const curOrder = n.get("order") === "desc" ? "desc" : "asc";
|
||||||
|
const curSort = n.get("sort") ?? "object_number";
|
||||||
|
const nextOrder = curSort === col && curOrder === "asc" ? "desc" : "asc";
|
||||||
|
n.set("sort", col); n.set("order", nextOrder); n.delete("offset");
|
||||||
|
});
|
||||||
|
|
||||||
|
// header cell: aria-sort = col===sort ? (order==='asc'?'ascending':'descending') : 'none'
|
||||||
|
// row: <tr onClick={() => navigate(`/objects/${o.id}?${params}`)} aria-selected={o.id===selectedId} ...>
|
||||||
|
// pagination: prev disabled offset===0; next disabled offset+limit>=total; page-size select sets limit + deletes offset
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Render loading via `Skeleton` rows; error → `objects.loadError`; empty → `objects.empty`. Visibility chips mirror the search-panel `<button aria-pressed>` pattern (set `visibility` param, delete `offset`). The "Updated" cell: format `o.updated_at` with `Intl.DateTimeFormat(i18n.language, { dateStyle:'medium', timeZone: useConfig().default_timezone })` (or a relative-time helper) — keep it a small local helper. **No `any`** (cast page items as `components["schemas"]["AdminObjectView"]`).
|
||||||
|
- [ ] **Step 2: i18n** — add `objects.columns.{number,name,visibility,location,count,updated}`, `objects.filter` (quick-filter placeholder), `objects.pageSize`, `objects.all` (or reuse `search.all`) to **both** `en.json` and `sv.json`.
|
||||||
|
- [ ] **Step 3: Stories** `objects-table.stories.tsx` — render inside a `MemoryRouter` (the preview provides providers; add a router if needed) with MSW returning a small page: `Default` (rows render), `Sorted` (assert `aria-sort` on the active header), `Empty`. Mirror the visibility-badge story format.
|
||||||
|
- [ ] **Step 4: Unit test** `objects-table.test.tsx` (RTL + MSW + MemoryRouter): rows render the columns; clicking a sortable header updates the URL `sort`/`order` and sets `aria-sort`; typing in the filter (debounced) sets `q`; a visibility chip sets `visibility`; pagination next/prev change `offset`; page-size sets `limit`. Use the search-panel test as a reference for MSW + router wiring.
|
||||||
|
- [ ] **Step 5:** `pnpm typecheck && pnpm lint && pnpm test -- objects-table`. **Commit** `feat(web): full-width sortable/filterable objects table with URL state (#44)`.
|
||||||
|
|
||||||
|
## Task 6: Wire the table into the page (table full-width; detail via Outlet placeholder)
|
||||||
|
**Files:** `web/src/objects/objects-page.tsx` (interim — full restructure in Phase 3).
|
||||||
|
- [ ] Make `ObjectsPage` render `ObjectsTable` full-width for now, keeping the nested `<Outlet/>` available but not as a fixed 20rem column (Phase 3 makes it a pane/drawer). Interim acceptable state: table fills the area; if a `:id` child route is active, render the detail below/over as a simple panel (Phase 3 makes it responsive). Remove the `index → SelectPrompt` route's visual prominence (the table is the landing view). **Verify** `pnpm test && pnpm build`. **Commit** `feat(web): objects table as the /objects landing view (#44)`.
|
||||||
|
|
||||||
|
> Note: Tasks 5–6 can be one commit if cleaner. The key is the table renders at `/objects` and row-click deep-links to `/objects/:id` with preserved query state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# PHASE 3 — Shell & responsive detail
|
||||||
|
|
||||||
|
## Task 7: `useMediaQuery` hook + `ui/tooltip.tsx` wrapper
|
||||||
|
**Files:** create `web/src/lib/use-media-query.ts`, `web/src/components/ui/tooltip.tsx`.
|
||||||
|
- [ ] **`use-media-query.ts`** (tiny, SSR-safe, mirrors `use-debounced-value`):
|
||||||
|
```ts
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
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 on = () => setMatches(mql.matches);
|
||||||
|
on(); mql.addEventListener("change", on);
|
||||||
|
return () => mql.removeEventListener("change", on);
|
||||||
|
}, [query]);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] **`ui/tooltip.tsx`** — wrap `@base-ui/react/tooltip` parts (Provider/Root/Trigger/Portal/Positioner/Popup) in the established `ui/*` style (mirror `ui/alert-dialog.tsx`: `data-slot`, `cn`, `render=` where a trigger delegates). Export a simple `<Tooltip content=…>{trigger}</Tooltip>` convenience plus the raw parts. **RUN a quick story/test** to confirm the Base UI composition (first tooltip in the repo — verify the part tree by running, like the combobox was). No `any`.
|
||||||
|
- [ ] Typecheck/lint. **Commit** `feat(web): useMediaQuery hook + Base UI tooltip wrapper (#44)`.
|
||||||
|
|
||||||
|
## Task 8: Collapsible icon sidebar
|
||||||
|
**Files:** `web/src/shell/app-shell.tsx` (+ optional `sidebar.stories.tsx`).
|
||||||
|
- [ ] Add lucide icons to each nav item (e.g. `Boxes`/`BookMarked`/`Users`/`Search`/`Tags` — pick sensible icons). Add a collapse toggle button; persist `collapsed` to `localStorage` (`sidebar-collapsed`); auto-collapse when `useMediaQuery("(max-width: 768px)")`. Expanded: icon + label (`w-44`). Collapsed: icon only (`~w-14`) with the label via the `ui/tooltip` (and `aria-label`/`title`). Preserve `NavLink` active styling; add `focus-visible` rings.
|
||||||
|
- [ ] **Story** `app-shell` sidebar or a extracted `Sidebar` component: `Expanded` / `Collapsed` (assert labels hidden + tooltips/`aria-label` present). If extracting a `Sidebar` component from `app-shell` makes it testable/storyable, do so (keep `app-shell` thin).
|
||||||
|
- [ ] Typecheck/lint/test. **Commit** `feat(web): collapsible icon sidebar (persisted, auto-collapse on narrow) (#44, #58)`.
|
||||||
|
|
||||||
|
## Task 9: Responsive detail — right pane (wide) / Drawer (narrow) at canonical `/objects/:id`
|
||||||
|
**Files:** `web/src/objects/objects-page.tsx`; possibly a small `object-detail-panel.tsx`.
|
||||||
|
- [ ] Restructure `ObjectsPage`: always render `ObjectsTable`; detect an active detail child with `useMatch("/objects/:id")` / `useMatch("/objects/:id/edit")`. When matched:
|
||||||
|
- **Wide** (`useMediaQuery("(min-width: 1024px)")`): render a right-hand pane (e.g. `grid-cols-[1fr_28rem]` when open, else `1fr`) containing `<Outlet/>`, with a close control (`navigate("/objects?"+params)`).
|
||||||
|
- **Narrow:** render `<Outlet/>` inside a Base UI `Drawer` (`swipeDirection="right"`, edge = right) over the table; closing the drawer navigates back to `/objects` (preserve query). **RUN to confirm** the Drawer part tree (Root/Portal/Backdrop/Popup/Close) — first Drawer in the repo; mirror the alert-dialog wrapper conventions.
|
||||||
|
- Remove the `index → SelectPrompt` route (the table is the landing view); `SelectPrompt` can be deleted if now unused (grep — it may also be used elsewhere; only remove if exclusively the objects index).
|
||||||
|
- `:id/edit` continues to render through the same `<Outlet/>` (pane/drawer), preserving today's "edit in the right area" behavior.
|
||||||
|
- [ ] **Test:** with a mocked `matchMedia`, `/objects/:id` renders detail in a pane (wide) and in a portaled Drawer (narrow, query via `within(document.body)`); closing returns to `/objects` with the table's query string intact; deep-linking `/objects/:id` directly renders table + open detail.
|
||||||
|
- [ ] Typecheck/lint/test/build. **Commit** `feat(web): responsive object detail (pane/drawer) at canonical /objects/:id (#44, #58)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# PHASE 4 — Verification
|
||||||
|
|
||||||
|
## Task 10: Final verification
|
||||||
|
- [ ] Backend: `cargo +nightly fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings`; `DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… cargo nextest run --workspace` (single clean run — don't run two concurrently; sqlx temp-DB contention produces fake failures).
|
||||||
|
- [ ] Web: `cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (index ≤ **165 KB gz** — lucide/tooltip/drawer land in the always-loaded shell; tree-shaken — verify and report the number).
|
||||||
|
- [ ] `pnpm test -- i18n` (en/sv parity for the new `objects.columns.*` etc.); `git grep -in 'biggus\|dickus' -- crates web/src || echo CLEAN`; `git status --short` clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
**Spec coverage:** sort/filter/q + filtered total + timestamps (T1–T3); full-width table with columns/sort/filter/pagination/URL-state (T4–T6); collapsible icon sidebar (T8); responsive pane/drawer + canonical `/objects/:id` (T7,T9); stories (T5,T7,T8); bundle/parity/codename (T10). ✓ Out of scope (Meili unification, detail-content #45, multi-select) not included. ✓
|
||||||
|
**Placeholder scan:** load-bearing logic (SQL builder, sort enum, URL-state wiring, sort toggle, responsive routing, media-query/tooltip) is concrete; routine table markup/classes are described to match existing patterns; the two novel Base UI primitives (Tooltip, Drawer) carry explicit "verify the part tree by running" steps (same approach that worked for the combobox), with canonical trees from the spec. No "TBD"/"add error handling".
|
||||||
|
**Type consistency:** `ObjectSort` enum + `ObjectQuery` (db) ↔ `ObjectListParams` (api) ↔ `useObjectsPage(ObjectListParams)` (web) align on sort/order/visibility/q; `AdminObjectView` gains `created_at`/`updated_at` (T1) consumed by the table's Updated column (T5). URL param names (`sort`/`order`/`visibility`/`q`/`limit`/`offset`) consistent across table read/write and the hook.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- `lucide-react` + Base UI tooltip/drawer/collapsible are already deps → no `pnpm-lock` churn.
|
||||||
|
- No DB migration (timestamps already exist).
|
||||||
|
- Watch the bundle: icons/tooltip/drawer are in the always-loaded shell, not a lazy chunk — if `check:size` exceeds 165, lazy-import the Drawer (only used at narrow widths) or trim.
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Objects Data-Overview Table + Responsive Shell — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-06
|
||||||
|
**Status:** Approved (brainstorming) — ready for implementation planning.
|
||||||
|
**Issues:** #44 (object list → table); subsumes #58 (responsive layout) for the shell.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Objects screen is where curators triage hundreds of records daily, but today
|
||||||
|
`web/src/objects/object-list.tsx` renders a **thin 20rem list** (object_number + name +
|
||||||
|
visibility badge) inside a master/detail grid, with no columns, sort, or filter. The
|
||||||
|
backend `GET /api/admin/objects` (`list_objects_paged`) takes only `limit`/`offset` and
|
||||||
|
orders by `object_number`. A *separate* `search-panel.tsx` (Meilisearch full-text, infinite
|
||||||
|
scroll, visibility filter) is a parallel browse UI with different ergonomics. Goal: a real,
|
||||||
|
scannable, sortable, filterable **data-overview table** plus a shell that adapts to viewport
|
||||||
|
width and gives every object a shareable URL.
|
||||||
|
|
||||||
|
### Facts established during exploration
|
||||||
|
- **Timestamps already exist.** The `object` table has `created_at` + `updated_at`
|
||||||
|
(`migrations/0003_object.sql`); `updated_at` is set to `now()` on every write; the db layer
|
||||||
|
already reads them into `CatalogueObject`. They are simply **not exposed in
|
||||||
|
`AdminObjectView`** — so adding an "Updated" column needs *no migration*, just two fields on
|
||||||
|
`AdminObjectView::from_object`.
|
||||||
|
- **Search is best-effort/optional** (`AppState.search: None` → the search endpoint 503s). So
|
||||||
|
the **Postgres-backed list must remain the always-available browse surface**; full-text
|
||||||
|
search is a layer on top, not a replacement.
|
||||||
|
- **No new dependencies needed:** `lucide-react` is already installed (nav icons); Base UI
|
||||||
|
ships `drawer`, `collapsible`, and `tooltip` primitives (the slide-in detail + sidebar).
|
||||||
|
|
||||||
|
### Decisions (from brainstorming)
|
||||||
|
1. **Layout:** a Linear/email-style shell — collapsible **icon sidebar**; a **full-width
|
||||||
|
objects table** as the overview; selecting a row opens detail as a **right-hand pane on
|
||||||
|
wide viewports / a slide-in drawer when narrow**; **`/objects/:id` is a canonical,
|
||||||
|
shareable URL**.
|
||||||
|
2. **Search:** **table-first.** The table gets Postgres-backed sort + visibility filter + a
|
||||||
|
quick text filter (object number/name). The dedicated Meilisearch Search screen stays as-is;
|
||||||
|
folding full-text into the table's search box is a **deferred follow-up**.
|
||||||
|
3. One milestone, **built in phases** (backend → table → shell/responsive/detail).
|
||||||
|
4. **Storybook** stories for meaningful new components (per the standing preference).
|
||||||
|
|
||||||
|
## 1. Shell: collapsible icon sidebar + responsive frame
|
||||||
|
`web/src/shell/app-shell.tsx`:
|
||||||
|
- The sidebar gains a **collapse toggle**; expanded = `w-44` (icon + label), collapsed = an
|
||||||
|
icon rail (`~w-14`, icon-only). State persisted in `localStorage` (e.g. `sidebar-collapsed`).
|
||||||
|
- Each nav item (`objects`, `vocabularies`, `authorities`, `search`, `fields`) gets a
|
||||||
|
`lucide-react` icon. When collapsed, the label is shown via a Base UI **`Tooltip`** on hover
|
||||||
|
and as the `aria-label`/`title` for AT.
|
||||||
|
- Below a width breakpoint the sidebar **auto-collapses** to the rail (the user can still
|
||||||
|
toggle). Nav `NavLink` active state + focus-visible rings preserved/added.
|
||||||
|
- This resolves #58 at the shell level (the per-screen master/detail responsiveness is handled
|
||||||
|
in §3).
|
||||||
|
|
||||||
|
## 2. Objects table (`/objects`)
|
||||||
|
Replace the narrow list with a **full-width table** filling the main content area.
|
||||||
|
|
||||||
|
**Columns (default):** Object № (sortable) · Name (sortable) · Visibility (badge; filterable)
|
||||||
|
· Current location · # objects · Updated (sortable). Real `<table>` semantics with
|
||||||
|
`scope="col"` headers and `aria-sort` on the active sort column.
|
||||||
|
|
||||||
|
**Toolbar (above the table):**
|
||||||
|
- A debounced **quick text filter** (`q`) — Postgres `ILIKE` on `object_number` + `object_name`
|
||||||
|
(always available; distinct from the Meili Search screen which searches descriptions/fields).
|
||||||
|
- **Visibility filter chips** (`all` / `draft` / `internal` / `public`), mirroring the search
|
||||||
|
panel's pattern (honest `<button aria-pressed>`).
|
||||||
|
- The **New** button (right-aligned).
|
||||||
|
|
||||||
|
**Sorting:** clicking a sortable header toggles sort column + direction (server-side); default
|
||||||
|
`object_number asc` (today's order). Reflected in `aria-sort`.
|
||||||
|
|
||||||
|
**"Updated" rendering:** relative ("2d", "1w") with an absolute tooltip, formatted in the
|
||||||
|
instance timezone/locale via `Intl` (`useConfig().default_timezone` + active language).
|
||||||
|
|
||||||
|
**Pagination (footer):** `from–to of total`, prev/next, and a **page-size selector**
|
||||||
|
(25/50/100/200 — backend caps at 200). Keep the **offset** model (it supports sort + a true
|
||||||
|
total cleanly; infinite scroll does not).
|
||||||
|
|
||||||
|
**URL-synced state:** `q`, `visibility`, `sort`, `order`, and the page offset live in the URL
|
||||||
|
query string (the search panel already does this for `q`/`visibility`). This makes the table
|
||||||
|
shareable, back-button-friendly, and preserves position across the row→detail→back round-trip.
|
||||||
|
|
||||||
|
**Row interaction:** click navigates to `/objects/:id` (canonical); the selected row is
|
||||||
|
highlighted; keyboard-navigable.
|
||||||
|
|
||||||
|
## 3. Detail presentation + canonical URL (`/objects/:id`)
|
||||||
|
- `/objects/:id` is the **canonical, shareable** address — opening the link loads the table and
|
||||||
|
reveals that object's detail.
|
||||||
|
- **Wide viewport:** detail renders as a **right-hand pane** beside the (compressed) table.
|
||||||
|
**Narrow viewport:** detail **slides in from the right as a Base UI `Drawer`** over the table,
|
||||||
|
with a backdrop. A close affordance returns to `/objects`, table state preserved via the URL.
|
||||||
|
- Implementation: nested routing — the `/objects` route renders the table; an `:id` child
|
||||||
|
controls the pane/drawer (presence of `:id` opens it). The pane-vs-drawer switch is by
|
||||||
|
viewport width (CSS breakpoint / a `matchMedia` hook); the `Drawer` is used only at narrow
|
||||||
|
widths.
|
||||||
|
- **Reuses the existing `ObjectDetail`.** Its *content* improvements (resolving
|
||||||
|
term/authority/`localized_text` to labels, grouping by field group) are **issue #45** and
|
||||||
|
explicitly out of scope here — this milestone changes where/how detail is presented, not its
|
||||||
|
internals.
|
||||||
|
|
||||||
|
## 4. Backend contract (`crates/api/src/admin_objects.rs`, `crates/db/src/catalog.rs`)
|
||||||
|
- **Query params on `GET /api/admin/objects`:** `sort` (enum: `object_number` |
|
||||||
|
`object_name` | `updated_at` | `created_at` | `visibility`; default `object_number`),
|
||||||
|
`order` (`asc` | `desc`; default `asc`), `visibility` (optional filter: draft|internal|public),
|
||||||
|
`q` (optional text). All optional; absent → today's behavior.
|
||||||
|
- **`list_objects_paged`** extended to accept the sort column + direction + filters. Build
|
||||||
|
`ORDER BY` from the **whitelisted enum** (never interpolate a raw client string — SQL-injection
|
||||||
|
safe) and `WHERE` clauses for `visibility = $` and/or `(object_number ILIKE $q OR object_name
|
||||||
|
ILIKE $q)`. **`count_objects`** takes the same filters so the total reflects the filtered set.
|
||||||
|
- **Expose timestamps:** add `created_at` + `updated_at` (RFC3339 strings) to `AdminObjectView`
|
||||||
|
and `AdminObjectView::from_object` (values already present on the domain object). No migration.
|
||||||
|
- Gated by `ViewInternal` as today. Regenerate `web/src/api/schema.d.ts`.
|
||||||
|
|
||||||
|
## 5. Frontend data layer (`web/src/api/queries.ts`)
|
||||||
|
- `useObjectsPage` gains `{ sort, order, visibility, q, limit, offset }`; the query key includes
|
||||||
|
them; use `placeholderData: keepPreviousData` so sorting/paging/filtering doesn't flash empty.
|
||||||
|
- A small `use-media-query`/`matchMedia` hook for the pane-vs-drawer breakpoint (if one doesn't
|
||||||
|
already exist).
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
`/objects?sort=…&order=…&visibility=…&q=…&offset=…` → `useObjectsPage(params)` →
|
||||||
|
`GET /api/admin/objects?…` (Postgres, sorted/filtered, with filtered total) → table renders
|
||||||
|
columns + `aria-sort` + pagination. Row click → `/objects/:id` (URL carries the table state) →
|
||||||
|
detail pane (wide) or `Drawer` (narrow) over the table → close → `/objects?…` restored.
|
||||||
|
|
||||||
|
## Error handling / edges
|
||||||
|
- List load error / empty: reuse the existing error + empty states (standardized on `Skeleton`
|
||||||
|
loading per #53 if convenient, else keep current).
|
||||||
|
- Invalid `sort`/`order`/`visibility` from a hand-edited URL: backend rejects unknown enum
|
||||||
|
values (422) or the handler falls back to defaults; the frontend clamps to known values.
|
||||||
|
- Quick filter with no matches: empty-state message; pagination shows `0 of 0`.
|
||||||
|
- Deep-linking `/objects/:id` for a missing/deleted object: existing 404 handling
|
||||||
|
(`useObject` → `objects.notFound`); the table still renders behind/beside.
|
||||||
|
- Narrow→wide resize while detail open: the pane/drawer swaps presentation without losing the
|
||||||
|
selected `:id`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
**Backend** (`#[sqlx::test]`, mirror `crates/api/tests/admin_catalog.rs`):
|
||||||
|
- `list_objects` honors `sort`+`order` (e.g. by `object_name desc`, by `updated_at`),
|
||||||
|
`visibility` filter, and `q` ILIKE; the `total` reflects the filter; default (no params)
|
||||||
|
matches today's `object_number asc`; an unknown `sort` value is rejected/falls back.
|
||||||
|
- `AdminObjectView` includes `created_at`/`updated_at`.
|
||||||
|
- OpenAPI regenerated.
|
||||||
|
|
||||||
|
**Frontend** (Vitest + RTL + MSW):
|
||||||
|
- Table renders the columns from a mocked page; a sortable header click updates the URL
|
||||||
|
(`sort`/`order`) and re-queries with `aria-sort`; visibility chips + quick filter update the
|
||||||
|
URL and query (debounced); pagination + page-size update offset/limit; row click navigates to
|
||||||
|
`/objects/:id` and the table state (URL) is preserved on back.
|
||||||
|
- Sidebar collapse toggles + persists to `localStorage`; collapsed rail shows tooltips/labels.
|
||||||
|
- Detail presents as a pane vs `Drawer` per a mocked `matchMedia` width.
|
||||||
|
- en/sv parity for new keys; no `any`/`eslint-disable`; no codename.
|
||||||
|
|
||||||
|
**Storybook** (per the standing preference — meaningful interactive components):
|
||||||
|
- The table **row** (default / selected / various visibility), the **sortable column header**
|
||||||
|
(idle / asc / desc), the **pagination control**, and the **collapsible sidebar** (expanded /
|
||||||
|
collapsed). Mirror the established story format.
|
||||||
|
|
||||||
|
**Bundle:** `pnpm check:size` — index chunk ≤ **165 KB gz** (lucide icons + any newly-used Base
|
||||||
|
UI primitives land in the always-loaded shell; tree-shaken lucide imports keep this small —
|
||||||
|
verify).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
1. `/objects` is a full-width, scannable table (№, name, visibility, location, count, updated)
|
||||||
|
with server-side sort, a visibility filter, and a quick text filter — all state in the URL.
|
||||||
|
2. Pagination has prev/next + a page-size selector + a true (filtered) total.
|
||||||
|
3. The sidebar collapses to an icon rail (persisted) and auto-collapses on narrow viewports.
|
||||||
|
4. Selecting a row opens detail as a right pane (wide) or slide-in drawer (narrow);
|
||||||
|
`/objects/:id` is a canonical shareable URL that opens that object directly.
|
||||||
|
5. Backend exposes `created_at`/`updated_at` and supports `sort`/`order`/`visibility`/`q`
|
||||||
|
(injection-safe, filtered total); OpenAPI regenerated.
|
||||||
|
6. Storybook stories for the row/header/pagination/sidebar; cargo + web typecheck/lint/test/build
|
||||||
|
green; index ≤ 165 KB gz; en/sv parity; no codename.
|
||||||
|
|
||||||
|
## Phasing (for the plan)
|
||||||
|
1. **Backend:** `sort`/`order`/`visibility`/`q` params + filtered count + expose timestamps +
|
||||||
|
OpenAPI regen.
|
||||||
|
2. **Table:** full-width table, columns, sortable headers, filters, pagination + page-size,
|
||||||
|
URL-synced state, `useObjectsPage` params (+ stories).
|
||||||
|
3. **Shell & detail:** collapsible icon sidebar (lucide + tooltip + persistence + auto-collapse),
|
||||||
|
responsive detail pane/drawer, canonical `/objects/:id` routing (+ stories).
|
||||||
|
|
||||||
|
## Out of scope → follow-ups
|
||||||
|
- **Meilisearch full-text unified into the table's search box** (graceful fallback when search
|
||||||
|
disabled) — deferred; the dedicated Search screen stays for now.
|
||||||
|
- **Object detail *content*** (term/authority/localized_text → labels, group-by-group) — **#45**.
|
||||||
|
- Multi-select / bulk actions (e.g. bulk visibility change); saved views/filters.
|
||||||
|
- Per-screen responsive work beyond the Objects shell (other master/detail screens) — remainder
|
||||||
|
of #58.
|
||||||
@@ -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() {
|
||||||
return (
|
const { t } = useTranslation();
|
||||||
<div className="grid h-full grid-cols-[20rem_1fr]">
|
const navigate = useNavigate();
|
||||||
<div className="overflow-hidden border-r">
|
const [searchParams] = useSearchParams();
|
||||||
<ObjectList />
|
|
||||||
</div>
|
// 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">
|
<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 />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</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 (
|
||||||
|
<div className="h-full">
|
||||||
|
{table}
|
||||||
|
{open && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ObjectDetailDrawer open={open} onClose={closeDetail} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</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