From 6a1eec02b50d92661545321c4038e9ef106a62f7 Mon Sep 17 00:00:00 2001 From: Prom3theu5 Date: Tue, 22 Sep 2026 23:07:23 +0100 Subject: [PATCH 1/2] feat(dashboard): add shared draft tags --- Cargo.lock | 2 + crates/keryx-core/src/types.rs | 13 + crates/keryx-db/src/entity/draft_tag.rs | 13 + crates/keryx-db/src/entity/mod.rs | 4 + crates/keryx-db/src/entity/tag.rs | 12 + crates/keryx-db/src/lib.rs | 5 +- crates/keryx-db/src/migration/m0002_tags.rs | 33 +++ crates/keryx-db/src/migration/mod.rs | 10 +- crates/keryx-db/src/store.rs | 157 ++++++++++- crates/keryx-db/src/store_tests.rs | 255 +++++++++++++++++ crates/keryx-db/src/types.rs | 52 ++++ crates/keryx-db/tests/adopt.rs | 4 +- crates/keryx-db/tests/parity.rs | 12 + crates/keryx-render/Cargo.toml | 1 + crates/keryx-render/assets/dashboard.css | 39 +++ crates/keryx-render/assets/dashboard.js | 294 +++++++++++++++++++- crates/keryx-render/src/lib.rs | 171 ++++++++++-- crates/keryx-server/Cargo.toml | 1 + crates/keryx-server/src/lib.rs | 253 ++++++++++++++++- tests/dashboard/README.md | 8 + tests/dashboard/playwright.config.mjs | 10 + tests/dashboard/server.mjs | 12 + tests/dashboard/tags.spec.mjs | 237 ++++++++++++++++ 23 files changed, 1550 insertions(+), 48 deletions(-) create mode 100644 crates/keryx-db/src/entity/draft_tag.rs create mode 100644 crates/keryx-db/src/entity/tag.rs create mode 100644 crates/keryx-db/src/migration/m0002_tags.rs create mode 100644 tests/dashboard/README.md create mode 100644 tests/dashboard/playwright.config.mjs create mode 100644 tests/dashboard/server.mjs create mode 100644 tests/dashboard/tags.spec.mjs diff --git a/Cargo.lock b/Cargo.lock index 364a2a5..e9e6c65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3257,6 +3257,7 @@ dependencies = [ "fulgur", "keryx-core", "scraper", + "serde_json", "usvg", "woff2-patched", ] @@ -3280,6 +3281,7 @@ dependencies = [ "keryx-store", "rand 0.9.5", "reqwest", + "rustls", "serde", "serde_json", "tempfile", diff --git a/crates/keryx-core/src/types.rs b/crates/keryx-core/src/types.rs index c507aed..d99da6d 100644 --- a/crates/keryx-core/src/types.rs +++ b/crates/keryx-core/src/types.rs @@ -321,6 +321,19 @@ pub struct UploadMetadata { pub cli_version: Option, } +/// Shared dashboard organisation metadata, independent of document summaries. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Tag { + pub id: String, + pub name: String, +} + +/// A dashboard row. Tags are never part of the CLI/TUI summary contract. +pub struct DashboardDraft { + pub summary: DraftSummary, + pub tags: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/keryx-db/src/entity/draft_tag.rs b/crates/keryx-db/src/entity/draft_tag.rs new file mode 100644 index 0000000..6cb1ada --- /dev/null +++ b/crates/keryx-db/src/entity/draft_tag.rs @@ -0,0 +1,13 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "draft_tags")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub draft_id: String, + #[sea_orm(primary_key, auto_increment = false)] + pub tag_id: String, +} +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/keryx-db/src/entity/mod.rs b/crates/keryx-db/src/entity/mod.rs index 0afa4e0..34bede2 100644 --- a/crates/keryx-db/src/entity/mod.rs +++ b/crates/keryx-db/src/entity/mod.rs @@ -6,10 +6,12 @@ //! columns are `bool`: INTEGER on SQLite, BOOLEAN on Postgres. pub mod draft; +pub mod draft_tag; pub mod draft_version; pub mod notification_delivery; pub mod notification_event; pub mod push_subscription; +pub mod tag; #[cfg(test)] mod tests { @@ -61,6 +63,8 @@ mod tests { }; } check!(draft::Entity); + check!(tag::Entity); + check!(draft_tag::Entity); check!(draft_version::Entity); check!(push_subscription::Entity); check!(notification_event::Entity); diff --git a/crates/keryx-db/src/entity/tag.rs b/crates/keryx-db/src/entity/tag.rs new file mode 100644 index 0000000..e3218c5 --- /dev/null +++ b/crates/keryx-db/src/entity/tag.rs @@ -0,0 +1,12 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "tags")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + pub name: String, +} +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/keryx-db/src/lib.rs b/crates/keryx-db/src/lib.rs index 2990a65..12db0a8 100644 --- a/crates/keryx-db/src/lib.rs +++ b/crates/keryx-db/src/lib.rs @@ -15,6 +15,7 @@ mod types; pub use store::{DatabaseConfig, DraftStore, SeaOrmStore}; pub use types::{ - normalize_wake_time, AvailabilityError, BlobRecord, NewUpload, PendingDelivery, ServedVersion, - UploadError, UploadOutcome, DEFAULT_DISABLE_REASON, + canonical_tag_name, normalize_wake_time, AvailabilityError, BlobRecord, DashboardTags, + NewUpload, PendingDelivery, ServedVersion, TagError, UploadError, UploadOutcome, + DEFAULT_DISABLE_REASON, }; diff --git a/crates/keryx-db/src/migration/m0002_tags.rs b/crates/keryx-db/src/migration/m0002_tags.rs new file mode 100644 index 0000000..c25f392 --- /dev/null +++ b/crates/keryx-db/src/migration/m0002_tags.rs @@ -0,0 +1,33 @@ +//! Add organisation metadata without rewriting any existing table. +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared( + r#" + CREATE TABLE tags (id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL UNIQUE); + CREATE TABLE draft_tags ( + draft_id TEXT NOT NULL REFERENCES drafts(id) ON DELETE CASCADE, + tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (draft_id, tag_id) + ); + CREATE INDEX draft_tags_tag_id_idx ON draft_tags(tag_id, draft_id); + "#, + ) + .await?; + Ok(()) + } + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("DROP TABLE draft_tags; DROP TABLE tags;") + .await?; + Ok(()) + } +} diff --git a/crates/keryx-db/src/migration/mod.rs b/crates/keryx-db/src/migration/mod.rs index 2f5f6f7..b651208 100644 --- a/crates/keryx-db/src/migration/mod.rs +++ b/crates/keryx-db/src/migration/mod.rs @@ -1,6 +1,7 @@ //! Schema migrations, tracked by SeaORM in `seaql_migrations`. mod m0001_baseline; +mod m0002_tags; use sea_orm_migration::prelude::*; @@ -15,7 +16,10 @@ pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec> { - vec![Box::new(m0001_baseline::Migration)] + vec![ + Box::new(m0001_baseline::Migration), + Box::new(m0002_tags::Migration), + ] } } @@ -45,17 +49,19 @@ mod tests { assert_eq!( names("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").await, [ + "draft_tags", "draft_versions", "drafts", "notification_deliveries", "notification_events", "push_subscriptions", "seaql_migrations", + "tags", ] ); assert_eq!( names("SELECT version FROM seaql_migrations").await, - [BASELINE_NAME] + [BASELINE_NAME, "m0002_tags"] ); } } diff --git a/crates/keryx-db/src/store.rs b/crates/keryx-db/src/store.rs index 3684dd1..bdbe38c 100644 --- a/crates/keryx-db/src/store.rs +++ b/crates/keryx-db/src/store.rs @@ -20,21 +20,30 @@ use keryx_core::ids::{new_draft_id, new_internal_id}; use keryx_core::now; use keryx_core::types::{ AvailabilityUpdate, DraftSummary, NotificationEvent, NotificationKind, PushSubscriptionInput, - PushSubscriptionSummary, VersionInfo, + PushSubscriptionSummary, Tag, VersionInfo, }; use crate::adopt::{self, Adoption}; use crate::entity::{ - draft, draft_version, notification_delivery, notification_event, push_subscription, + draft, draft_tag, draft_version, notification_delivery, notification_event, push_subscription, + tag, }; use crate::types::{ - normalize_wake_time, AvailabilityError, BlobRecord, NewUpload, PendingDelivery, ServedVersion, - UploadError, UploadOutcome, DEFAULT_DISABLE_REASON, + canonical_tag_name, normalize_wake_time, AvailabilityError, BlobRecord, DashboardTags, + NewUpload, PendingDelivery, ServedVersion, TagError, UploadError, UploadOutcome, + DEFAULT_DISABLE_REASON, }; /// Draft and version metadata, availability, and the notification outbox. #[async_trait] pub trait DraftStore: Send + Sync { + /// Load the catalogue and all live draft assignments without version joins. + async fn dashboard_tags(&self) -> Result; + /// Create-or-find and attach atomically. Writes only organisation metadata. + async fn attach_tag(&self, draft_id: &str, name: &str) -> Result, TagError>; + /// Remove one assignment; repeated removal is a successful no-op. + async fn detach_tag(&self, draft_id: &str, tag_id: &str) -> Result, TagError>; + /// A trivial query, for `/healthz`. async fn ping(&self) -> Result<()>; @@ -250,6 +259,22 @@ impl SeaOrmStore { .and_then(|row| row.try_get_by_index::>(0).expect("peek column")) } + async fn lock_tag_draft( + &self, + tx: &DatabaseTransaction, + draft_id: &str, + ) -> Result<(), TagError> { + let mut query = + draft::Entity::find_by_id(draft_id).filter(draft::Column::DeletedAt.is_null()); + if tx.get_database_backend() == DbBackend::Postgres { + query = query.lock_exclusive(); + } + if query.one(tx).await?.is_none() { + return Err(TagError::DraftNotFound); + } + Ok(()) + } + /// Every write transaction begins immediate. A deferred read-then-write /// transaction fails with SQLITE_BUSY regardless of the busy timeout, and /// record_upload is exactly that shape. Ignored on Postgres. @@ -453,6 +478,108 @@ struct DeliveryRow { #[async_trait] impl DraftStore for SeaOrmStore { + async fn dashboard_tags(&self) -> Result { + let catalogue: Vec = tag::Entity::find() + .order_by_asc(tag::Column::Name) + .all(&self.db) + .await? + .into_iter() + .map(|tag| Tag { + id: tag.id, + name: tag.name, + }) + .collect(); + let assignments = draft_tag::Entity::find() + .filter( + draft_tag::Column::DraftId.in_subquery( + Query::select() + .column(draft::Column::Id) + .from(draft::Entity) + .and_where(draft::Column::DeletedAt.is_null()) + .to_owned(), + ), + ) + .all(&self.db) + .await?; + let by_id: std::collections::HashMap<_, _> = + catalogue.iter().map(|tag| (tag.id.as_str(), tag)).collect(); + let mut grouped = std::collections::HashMap::>::new(); + for assignment in assignments { + if let Some(tag) = by_id.get(assignment.tag_id.as_str()) { + grouped + .entry(assignment.draft_id) + .or_default() + .push((*tag).clone()); + } + } + for tags in grouped.values_mut() { + tags.sort_by(|a, b| a.name.cmp(&b.name)); + } + Ok(DashboardTags { + catalogue, + assignments: grouped, + }) + } + + async fn attach_tag(&self, draft_id: &str, name: &str) -> Result, TagError> { + let name = canonical_tag_name(name)?; + let tx = self.begin_write().await?; + self.lock_tag_draft(&tx, draft_id).await?; + let current = assigned_tags(&tx, draft_id).await?; + if current.iter().any(|tag| tag.name == name) { + tx.commit().await?; + return Ok(current); + } + if current.len() >= 20 { + return Err(TagError::Invalid( + "A draft can have at most 20 tags.".into(), + )); + } + tag::Entity::insert(tag::ActiveModel { + id: Set(new_internal_id()), + name: Set(name.clone()), + }) + .on_conflict( + OnConflict::column(tag::Column::Name) + .do_nothing() + .to_owned(), + ) + .try_insert() + .exec(&tx) + .await?; + let tag = tag::Entity::find() + .filter(tag::Column::Name.eq(name)) + .one(&tx) + .await? + .ok_or_else(|| sea_orm::DbErr::RecordNotFound("Tag insert returned no tag".into()))?; + draft_tag::Entity::insert(draft_tag::ActiveModel { + draft_id: Set(draft_id.into()), + tag_id: Set(tag.id), + }) + .on_conflict( + OnConflict::columns([draft_tag::Column::DraftId, draft_tag::Column::TagId]) + .do_nothing() + .to_owned(), + ) + .try_insert() + .exec(&tx) + .await?; + let tags = assigned_tags(&tx, draft_id).await?; + tx.commit().await?; + Ok(tags) + } + + async fn detach_tag(&self, draft_id: &str, tag_id: &str) -> Result, TagError> { + let tx = self.begin_write().await?; + self.lock_tag_draft(&tx, draft_id).await?; + draft_tag::Entity::delete_by_id((draft_id.to_owned(), tag_id.to_owned())) + .exec(&tx) + .await?; + let tags = assigned_tags(&tx, draft_id).await?; + tx.commit().await?; + Ok(tags) + } + async fn ping(&self) -> Result<()> { let backend: DbBackend = self.db.get_database_backend(); self.db @@ -1054,6 +1181,28 @@ impl DraftStore for SeaOrmStore { } } +async fn assigned_tags(db: &C, draft_id: &str) -> Result, DbErr> { + Ok(tag::Entity::find() + .filter( + tag::Column::Id.in_subquery( + Query::select() + .column(draft_tag::Column::TagId) + .from(draft_tag::Entity) + .and_where(draft_tag::Column::DraftId.eq(draft_id)) + .to_owned(), + ), + ) + .order_by_asc(tag::Column::Name) + .all(db) + .await? + .into_iter() + .map(|tag| Tag { + id: tag.id, + name: tag.name, + }) + .collect()) +} + #[cfg(test)] #[path = "store_tests.rs"] mod tests; diff --git a/crates/keryx-db/src/store_tests.rs b/crates/keryx-db/src/store_tests.rs index 1e6a307..42d7958 100644 --- a/crates/keryx-db/src/store_tests.rs +++ b/crates/keryx-db/src/store_tests.rs @@ -751,3 +751,258 @@ async fn two_migrators_started_together_against_postgres_both_succeed() { } assert_eq!(created, 1, "exactly one pod creates the schema"); } + +// Compare complete records, including timestamps, blob pointers and the outbox. +async fn document_records( + store: &SeaOrmStore, +) -> ( + Vec, + Vec, + Vec, + Vec, + Vec, +) { + let db = store.connection(); + ( + draft::Entity::find() + .order_by_asc(draft::Column::Id) + .all(db) + .await + .unwrap(), + draft_version::Entity::find() + .order_by_asc(draft_version::Column::Id) + .all(db) + .await + .unwrap(), + notification_event::Entity::find().all(db).await.unwrap(), + notification_delivery::Entity::find().all(db).await.unwrap(), + push_subscription::Entity::find().all(db).await.unwrap(), + ) +} + +#[tokio::test] +async fn tags_only_write_organisation_metadata_and_survive_document_changes() { + let store = SeaOrmStore::open_test().await; + store + .upsert_push_subscription(&subscription("https://push.test/tags", None)) + .await + .unwrap(); + let first = record( + &store, + "Taggedexact original bytes", + None, + &UploadMetadata::default(), + ) + .await + .unwrap(); + let id = &first.draft_id; + assert!(store.dashboard_tags().await.unwrap().assignments.is_empty()); + let before = document_records(&store).await; + let tags = store + .attach_tag(id, " Release Planning ") + .await + .unwrap(); + assert_eq!(tags[0].name, "release planning"); + assert!(!before.2.is_empty()); + assert!(!before.3.is_empty()); + assert!(!before.4.is_empty()); + assert_eq!(document_records(&store).await, before); + assert_eq!( + store.attach_tag(id, "RELEASE PLANNING").await.unwrap(), + tags + ); + assert_eq!(document_records(&store).await, before); + let other = record(&store, "other", None, &UploadMetadata::default()) + .await + .unwrap(); + let before = document_records(&store).await; + assert_eq!( + store + .attach_tag(&other.draft_id, "release planning") + .await + .unwrap(), + tags + ); + assert_eq!(document_records(&store).await, before); + assert!(store.detach_tag(id, &tags[0].id).await.unwrap().is_empty()); + assert_eq!(document_records(&store).await, before); + assert!(store.detach_tag(id, &tags[0].id).await.unwrap().is_empty()); + assert_eq!(document_records(&store).await, before); + assert_eq!(store.dashboard_tags().await.unwrap().catalogue, tags); + store.attach_tag(id, "release planning").await.unwrap(); + record( + &store, + "updated bytes", + Some(id.clone()), + &UploadMetadata::default(), + ) + .await + .unwrap(); + store + .set_availability(id, &AvailabilityUpdate::Disabled { reason: None }) + .await + .unwrap(); + assert_eq!(store.dashboard_tags().await.unwrap().assignments[id], tags); + store + .set_availability( + id, + &AvailabilityUpdate::Snoozed { + until: "2099-01-01T00:00:00Z".into(), + }, + ) + .await + .unwrap(); + assert_eq!(store.dashboard_tags().await.unwrap().assignments[id], tags); + store.soft_delete_draft(id).await.unwrap(); + assert!(!store + .dashboard_tags() + .await + .unwrap() + .assignments + .contains_key(id)); + assert!(matches!( + store.attach_tag(id, "nope").await, + Err(TagError::DraftNotFound) + )); + assert!(matches!( + store.detach_tag(id, &tags[0].id).await, + Err(TagError::DraftNotFound) + )); + store.purge_deleted_drafts().await.unwrap(); + assert!(draft_tag::Entity::find() + .filter(draft_tag::Column::DraftId.eq(id)) + .all(store.connection()) + .await + .unwrap() + .is_empty()); + store.purge_draft(&other.draft_id).await.unwrap(); + assert!(draft_tag::Entity::find() + .all(store.connection()) + .await + .unwrap() + .is_empty()); + assert_eq!(store.dashboard_tags().await.unwrap().catalogue, tags); +} + +#[tokio::test] +async fn tag_limits_and_concurrent_assignments_are_atomic() { + let store = SeaOrmStore::open_test().await; + let first = record(&store, "one", None, &UploadMetadata::default()) + .await + .unwrap(); + let second = record(&store, "two", None, &UploadMetadata::default()) + .await + .unwrap(); + let before = document_records(&store).await; + let (a, b, c) = tokio::join!( + store.attach_tag(&first.draft_id, "Shared"), + store.attach_tag(&first.draft_id, " shared "), + store.attach_tag(&second.draft_id, "SHARED"), + ); + assert_eq!(a.as_ref().unwrap(), b.as_ref().unwrap()); + assert_eq!(a.unwrap(), c.unwrap()); + for invalid in [ + "", + " ", + "a\tb", + "x\n", + "é", + "", + "a/b", + "abcdefghijklmnopqrstuvwxyz1234567", + ] { + assert!( + matches!( + store.attach_tag(&first.draft_id, invalid).await, + Err(TagError::Invalid(_)) + ), + "{invalid:?}" + ); + } + store + .attach_tag(&first.draft_id, "abcdefghijklmnopqrstuvwxyz123456") + .await + .unwrap(); + for n in 0..17 { + store + .attach_tag(&first.draft_id, &format!("tag-{n}")) + .await + .unwrap(); + } + let (a, b) = tokio::join!( + store.attach_tag(&first.draft_id, "last-a"), + store.attach_tag(&first.draft_id, "last-b") + ); + assert_eq!(usize::from(a.is_ok()) + usize::from(b.is_ok()), 1); + assert!(matches!(a.err().or(b.err()), Some(TagError::Invalid(_)))); + assert_eq!( + store + .attach_tag(&first.draft_id, "shared") + .await + .unwrap() + .len(), + 20 + ); + assert_eq!(store.dashboard_tags().await.unwrap().catalogue.len(), 20); + assert_eq!(document_records(&store).await, before); + assert!(matches!( + store.attach_tag("missing", "valid").await, + Err(TagError::DraftNotFound) + )); +} + +#[tokio::test] +async fn populated_baseline_upgrade_preserves_records_and_tags_persist_on_reopen() { + use sea_orm_migration::MigratorTrait; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("upgrade.sqlite"); + let db = crate::connect::connect_sqlite(&path, None).await.unwrap(); + crate::migration::Migrator::up(&db, Some(1)).await.unwrap(); + let store = SeaOrmStore { db }; + let uploaded = record( + &store, + "immutable content", + None, + &UploadMetadata::default(), + ) + .await + .unwrap(); + let before = document_records(&store).await; + crate::migration::Migrator::up(store.connection(), None) + .await + .unwrap(); + assert_eq!(document_records(&store).await, before); + assert!(store.dashboard_tags().await.unwrap().catalogue.is_empty()); + let tags = store + .attach_tag(&uploaded.draft_id, "persisted") + .await + .unwrap(); + assert_eq!(document_records(&store).await, before); + store.db.close().await.unwrap(); + let (store, _) = SeaOrmStore::open_sqlite(&path, false).await.unwrap(); + assert_eq!( + store.dashboard_tags().await.unwrap().assignments[&uploaded.draft_id], + tags + ); + assert_eq!(document_records(&store).await, before); +} + +#[tokio::test] +async fn tags_upgrade_a_populated_baseline_on_either_backend() { + use sea_orm_migration::MigratorTrait; + let store = SeaOrmStore::open_test().await; + // Only this test's fresh database/schema is rolled back to the baseline. + crate::migration::Migrator::down(store.connection(), Some(1)) + .await + .unwrap(); + record(&store, "baseline", None, &UploadMetadata::default()) + .await + .unwrap(); + let before = document_records(&store).await; + crate::migration::Migrator::up(store.connection(), None) + .await + .unwrap(); + assert_eq!(document_records(&store).await, before); + assert!(store.dashboard_tags().await.unwrap().catalogue.is_empty()); + assert!(store.dashboard_tags().await.unwrap().assignments.is_empty()); +} diff --git a/crates/keryx-db/src/types.rs b/crates/keryx-db/src/types.rs index 53dce1d..0e1660c 100644 --- a/crates/keryx-db/src/types.rs +++ b/crates/keryx-db/src/types.rs @@ -122,3 +122,55 @@ pub struct PendingDelivery { pub auth: String, pub attempts: i64, } + +/// Catalogue and assignments loaded in a batch for dashboard rendering. +#[derive(Default)] +pub struct DashboardTags { + pub catalogue: Vec, + pub assignments: std::collections::HashMap>, +} + +#[derive(Debug)] +pub enum TagError { + DraftNotFound, + Invalid(String), + Other(sea_orm::DbErr), +} +impl std::fmt::Display for TagError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DraftNotFound => write!(f, "Draft not found."), + Self::Invalid(message) => write!(f, "{message}"), + Self::Other(error) => write!(f, "{error}"), + } + } +} +impl std::error::Error for TagError {} +impl From for TagError { + fn from(error: sea_orm::DbErr) -> Self { + Self::Other(error) + } +} + +/// Canonical names use only ASCII letters, digits, spaces and hyphens. +pub fn canonical_tag_name(value: &str) -> Result { + if !value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b' ' || b == b'-') + { + return Err(TagError::Invalid( + "Use letters, digits, spaces and hyphens only.".into(), + )); + } + let name = value + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase(); + if name.is_empty() || name.len() > 32 { + return Err(TagError::Invalid( + "Tag names must contain 1 to 32 characters.".into(), + )); + } + Ok(name) +} diff --git a/crates/keryx-db/tests/adopt.rs b/crates/keryx-db/tests/adopt.rs index b0d62aa..11eab71 100644 --- a/crates/keryx-db/tests/adopt.rs +++ b/crates/keryx-db/tests/adopt.rs @@ -24,7 +24,7 @@ async fn a_fresh_database_is_created_by_the_migrator_not_adopted() { assert_eq!(adoption, Adoption::Fresh); assert_eq!( integer(&db, "SELECT COUNT(*) FROM seaql_migrations").await, - 1 + 2 ); assert_eq!( backups_in(dir.path()), @@ -50,7 +50,7 @@ async fn adoption_is_idempotent_and_writes_exactly_one_baseline_row() { assert_eq!(second, Adoption::Managed, "user_version {user_version}"); assert_eq!( strings(&db, "SELECT version FROM seaql_migrations").await, - ["m0001_baseline"] + ["m0001_baseline", "m0002_tags"] ); // The pragma is left alone, so an older Keryx still understands the file. assert_eq!( diff --git a/crates/keryx-db/tests/parity.rs b/crates/keryx-db/tests/parity.rs index d287f75..8c584aa 100644 --- a/crates/keryx-db/tests/parity.rs +++ b/crates/keryx-db/tests/parity.rs @@ -177,6 +177,18 @@ async fn assert_parity(name: &str, legacy: &Path, dir: &Path) { &format!("{name}.rows"), &serde_json::to_value(all_rows(&adopted).await).unwrap(), ); + // New organisation tables are additive; historical golden rows stay unchanged. + for table in ["tags", "draft_tags"] { + assert_eq!( + common::integer(&adopted, &format!("SELECT COUNT(*) FROM {table}")).await, + 0 + ); + } + let foreign_keys = rows(&adopted, "PRAGMA foreign_key_list(draft_tags)").await; + assert_eq!(foreign_keys.len(), 2); + for key in foreign_keys { + assert_eq!(key.try_get_by_index::(6).unwrap(), "CASCADE"); + } adopted.close().await.unwrap(); // ...and the store answers exactly what the old query layer answered. diff --git a/crates/keryx-render/Cargo.toml b/crates/keryx-render/Cargo.toml index acba8ca..14f6f49 100644 --- a/crates/keryx-render/Cargo.toml +++ b/crates/keryx-render/Cargo.toml @@ -14,5 +14,6 @@ chrono.workspace = true fulgur.workspace = true keryx-core.workspace = true scraper.workspace = true +serde_json.workspace = true usvg.workspace = true woff2-patched.workspace = true diff --git a/crates/keryx-render/assets/dashboard.css b/crates/keryx-render/assets/dashboard.css index a85f5c7..83c7ae7 100644 --- a/crates/keryx-render/assets/dashboard.css +++ b/crates/keryx-render/assets/dashboard.css @@ -13,6 +13,9 @@ --blue: #2563eb; --blue-strong: #1d4ed8; --blue-muted: #eff6ff; + --tag-text: #be185d; + --tag-muted: #fdf2f8; + --tag-border: #f9a8d4; --green: #15803d; --green-muted: #f0fdf4; --red: #b91c1c; @@ -40,6 +43,9 @@ --blue: #60a5fa; --blue-strong: #93c5fd; --blue-muted: #13243b; + --tag-text: #f9a8d4; + --tag-muted: #301526; + --tag-border: #83345e; --green: #4ade80; --green-muted: #10271a; --red: #fca5a5; @@ -66,6 +72,9 @@ --blue: #60a5fa; --blue-strong: #93c5fd; --blue-muted: #13243b; + --tag-text: #f9a8d4; + --tag-muted: #301526; + --tag-border: #83345e; --green: #4ade80; --green-muted: #10271a; --red: #fca5a5; @@ -411,3 +420,33 @@ dialog::backdrop { background: rgba(15, 23, 42, .52); } .detail { padding: 24px 17px 30px; } .menu { right: -6px; } } + +/* Shared organisation tags use the dashboard's theme tokens. */ +.tag-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.draft-description + .tag-chips, #tag-selections:not(:empty) { margin-top: 10px; } +.tag-chip { font: inherit; font-size: 12px; border: 1px solid var(--tag-border); border-radius: 5px; padding: 4px 8px; background: var(--tag-muted); color: var(--tag-text); cursor: pointer; overflow-wrap: anywhere; } +.tag-chip:disabled { opacity: .6; cursor: default; } +.tag-more, .tag-help { font-size: 12px; color: var(--text-muted); } +.detail-tags { margin: 20px 0; } +.detail-tags h3 { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; } +.tag-filter { position: relative; font-size: 13px; } +.tag-filter summary { cursor: pointer; padding: 8px; border: 1px solid var(--line-strong); border-radius: 6px; } +.tag-filter-menu { position: absolute; top: 100%; left: 0; width: 280px; max-width: calc(100vw - 48px); z-index: 20; padding: 14px; background: var(--surface-raised); border: 1px solid var(--line-strong); border-radius: 6px; } +.tag-filter-menu label { display: block; padding: 6px 0; } +#tag-filter-options { max-height: 240px; overflow-y: auto; } +#tag-input, #tag-filter-search { width: 100%; font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--line-strong); border-radius: 5px; padding: 9px; } +#tag-dialog { width: min(420px, calc(100vw - 32px)); } +#tag-dialog label { display: block; margin-bottom: 6px; } +#tag-suggestions { max-height: 310px; overflow-y: auto; margin-top: 8px; } +#tag-suggestions [role="option"] { padding: 9px; cursor: pointer; border-radius: 4px; } +#tag-suggestions [aria-selected="true"] { color: var(--tag-text); background: var(--tag-muted); } +#tag-suggestions [aria-disabled="true"] { color: var(--text-muted); cursor: default; } +#tag-error { color: var(--red); } +#tag-error:empty { display: none; } +#tag-sort-help { font-size: 12px; color: var(--text-muted); } +.tag-chip:focus-visible, .tag-filter summary:focus-visible, #tag-input:focus-visible { outline: 2px solid var(--tag-text); outline-offset: 2px; } +@media (max-width: 720px) { + .filters { position: relative; flex-wrap: wrap; overflow: visible; } + .tag-filter { position: static; } + .tag-filter-menu { left: 0; right: auto; width: min(100%, 320px); } +} diff --git a/crates/keryx-render/assets/dashboard.js b/crates/keryx-render/assets/dashboard.js index 24af9ad..19fad4b 100644 --- a/crates/keryx-render/assets/dashboard.js +++ b/crates/keryx-render/assets/dashboard.js @@ -29,6 +29,252 @@ var selectedId = null; var view = "active"; + // Tags are dashboard metadata. The modal owns its target and survives snapshots. + var catalogueNode = document.getElementById("tag-catalogue"); + var catalogue = catalogueNode ? JSON.parse(catalogueNode.dataset.tags) : []; + var selectedTags = new Set(); + var untagged = false; + var tagDialog = document.getElementById("tag-dialog"); + var tagInput = document.getElementById("tag-input"); + var tagModal = null; + var tagTimer = null; + var tagMenu = document.getElementById("tag-filter"); + var removingTags = new Set(); + + function rowTags(row) { return JSON.parse(row.dataset.tags || "[]"); } + function canonicalTag(value) { + if (!/^[a-zA-Z0-9 -]*$/.test(value)) return null; + var name = value.trim().replace(/ +/g, " ").toLowerCase(); + return name.length > 0 && name.length <= 32 ? name : null; + } + function tagButton(text, label, action) { + var button = document.createElement("button"); + button.type = "button"; + button.className = "tag-chip"; + button.textContent = text; + button.setAttribute("aria-label", label); + button.addEventListener("click", action); + return button; + } + function clearTags() { + selectedTags.clear(); untagged = false; applyFilters(); syncUrl(); + document.getElementById("tag-filter-summary").focus(); + } + function chooseTag(id) { + untagged = false; selectedTags.add(id); applyFilters(); syncUrl(); + } + function matchesTags(row) { + var tags = rowTags(row); + if (untagged) return tags.length === 0; + return !selectedTags.size || tags.some(function (tag) { return selectedTags.has(tag.id); }); + } + function renderTagFilters(query) { + if (!managementEnabled) return; + var counts = new Map(); + var empty = 0; + rows.filter(function (row) { return matches(row, query); }).forEach(function (row) { + var tags = rowTags(row); + if (!tags.length) empty += 1; + tags.forEach(function (tag) { counts.set(tag.id, (counts.get(tag.id) || 0) + 1); }); + }); + var options = document.getElementById("tag-filter-options"); + var focusedId = document.activeElement && document.activeElement.dataset.tagChoice; + options.replaceChildren(); + var filterQuery = document.getElementById("tag-filter-search").value.trim().toLowerCase(); + catalogue.filter(function (tag) { return tag.name.includes(filterQuery); }).forEach(function (tag) { + var label = document.createElement("label"); + var box = document.createElement("input"); + box.type = "checkbox"; box.checked = selectedTags.has(tag.id); box.dataset.tagChoice = tag.id; + box.addEventListener("change", function () { + untagged = false; + if (box.checked) selectedTags.add(tag.id); else selectedTags.delete(tag.id); + applyFilters(); syncUrl(); + }); + label.append(box, document.createTextNode(" " + tag.name + " (" + (counts.get(tag.id) || 0) + ")")); + options.append(label); + if (focusedId === tag.id) box.focus(); + }); + document.getElementById("tag-untagged").checked = untagged; + document.getElementById("untagged-count").textContent = "(" + empty + ")"; + document.getElementById("tag-filter-summary").textContent = untagged ? "Tags: untagged" : (selectedTags.size ? "Tags: " + selectedTags.size + " selected" : "Tags: all"); + var chips = document.getElementById("tag-selections"); + chips.replaceChildren(); + selectedTags.forEach(function (id) { + var tag = catalogue.find(function (tag) { return tag.id === id; }); + var name = tag ? tag.name : "Missing tag " + id; + chips.append(tagButton(name + " ×", "Clear filter " + name, function () { + selectedTags.delete(id); applyFilters(); syncUrl(); + document.getElementById("tag-filter-summary").focus(); + })); + }); + if (untagged) chips.append(tagButton("Untagged ×", "Clear untagged filter", clearTags)); + if (selectedTags.size || untagged) chips.append(tagButton("Clear all", "Clear all tag filters", clearTags)); + document.getElementById("clear-empty-tags").hidden = emptyResults.hidden || (!selectedTags.size && !untagged); + document.getElementById("tag-sort-help").hidden = sort.value !== "tag"; + } + function renderDetailTags(row) { + var container = document.getElementById("detail-tags"); + if (!container) return; + container.replaceChildren(); + rowTags(row).forEach(function (tag) { + var button = tagButton(tag.name + " ×", "Remove tag " + tag.name, function () { + var key = row.dataset.draftId + "/" + tag.id; + if (removingTags.has(key)) return; + removingTags.add(key); button.disabled = true; + tagRequest(row.dataset.draftId, tag.id).then(function () { + refreshDashboard(); + }).catch(function (error) { showToast(error.message, true); }).finally(function () { + removingTags.delete(key); + var current = findRow(selectedId); + if (current) renderDetailTags(current); + }); + }); + button.disabled = removingTags.has(row.dataset.draftId + "/" + tag.id); + container.append(button); + }); + var add = tagButton("+ Add tag", "Add tag", function () { openTagDialog(row); }); + add.id = "add-tag"; add.className = "button"; + container.append(add); + } + function tagRequest(draftId, tagId, name) { + return fetch("/api/dashboard/drafts/" + encodeURIComponent(draftId) + "/tags" + (tagId ? "/" + encodeURIComponent(tagId) : ""), { + method: tagId ? "DELETE" : "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: tagId ? undefined : JSON.stringify({ name: name }) + }).then(function (response) { + return response.json().then(function (body) { + if (!response.ok) throw new Error(body.error || "Tags could not be saved."); + return body; + }); + }); + } + function cancelTagTimer() { clearTimeout(tagTimer); tagTimer = null; } + function closeTagDialog() { + cancelTagTimer(); tagModal = null; tagDialog.close(); + var trigger = document.getElementById("add-tag"); + var detail = document.getElementById("draft-detail"); + if (!trigger || detail.classList.contains("empty")) trigger = search; + trigger.focus(); + } + function openTagDialog(row) { + cancelTagTimer(); + tagModal = { draftId: row.dataset.draftId, pending: false, composing: false, waiting: false, choices: [], active: -1 }; + tagInput.value = ""; tagInput.disabled = false; + document.getElementById("tag-target").textContent = "To " + row.dataset.title; + document.getElementById("tag-error").textContent = ""; + reconcileTagSuggestions(); tagDialog.showModal(); tagInput.focus(); + } + function reconcileTagSuggestions() { + if (!tagModal) return; + var row = findRow(tagModal.draftId); + if (!row) { closeTagDialog(); showToast("The draft is no longer available for tagging.", true); return; } + if (tagModal.pending || tagModal.waiting || tagModal.composing) return; + var previous = tagModal.choices[tagModal.active]; + var assigned = new Set(rowTags(row).map(function (tag) { return tag.id; })); + var query = tagInput.value.trim().replace(/ +/g, " ").toLowerCase(); + function rank(tag) { return tag.name === query ? 0 : tag.name.startsWith(query) ? 1 : 2; } + var choices = catalogue.filter(function (tag) { return tag.name.includes(query); }).sort(function (a, b) { + return rank(a) - rank(b) || a.name.localeCompare(b.name); + }).slice(0, 8).map(function (tag) { return { id: tag.id, name: tag.name, added: assigned.has(tag.id) }; }); + var canonical = canonicalTag(tagInput.value); + if (canonical && !catalogue.some(function (tag) { return tag.name === canonical; })) { + choices = choices.slice(0, 7); + choices.push({ id: "create:" + canonical, name: canonical, create: true }); + } + tagModal.choices = choices; + tagModal.active = previous ? choices.findIndex(function (choice) { return choice.id === previous.id && !choice.added; }) : -1; + if (tagModal.active < 0) tagModal.active = choices.findIndex(function (choice) { return !choice.added; }); + renderTagSuggestions(); + } + function renderTagSuggestions() { + var list = document.getElementById("tag-suggestions"); + list.replaceChildren(); tagInput.removeAttribute("aria-activedescendant"); + if (!tagModal) return; + var blocked = tagModal.waiting || tagModal.composing || tagModal.pending; + tagInput.setAttribute("aria-expanded", String(!blocked && tagModal.choices.length > 0)); + document.getElementById("tag-status").textContent = tagModal.pending ? "Saving…" : blocked ? "Updating suggestions…" : tagModal.choices.length + " options"; + if (blocked) return; + tagModal.choices.forEach(function (choice, index) { + var option = document.createElement("div"); + option.id = "tag-option-" + index; option.setAttribute("role", "option"); + option.setAttribute("aria-selected", String(index === tagModal.active)); + option.setAttribute("aria-disabled", String(Boolean(choice.added))); + option.textContent = choice.create ? '+ Create "' + choice.name + '"' : choice.name + (choice.added ? " · Added" : ""); + option.addEventListener("mousedown", function (event) { event.preventDefault(); }); + option.addEventListener("click", function () { submitTag(index); }); + list.append(option); + if (index === tagModal.active) tagInput.setAttribute("aria-activedescendant", option.id); + }); + } + function submitTag(index) { + var session = tagModal; + if (!session || session.pending || session.waiting || session.composing) return; + var choice = session.choices[index]; + if (!choice || choice.added) return; + session.pending = true; tagInput.disabled = true; + document.getElementById("tag-error").textContent = ""; renderTagSuggestions(); + tagRequest(session.draftId, null, choice.name).then(function () { + if (tagModal === session) closeTagDialog(); + refreshDashboard(); + }).catch(function (error) { + if (tagModal !== session) return; + session.pending = false; tagInput.disabled = false; + document.getElementById("tag-error").textContent = error.message; + reconcileTagSuggestions(); tagInput.focus(); + }); + } + function debounceTags() { + if (!tagModal) return; + cancelTagTimer(); tagModal.waiting = true; tagModal.active = -1; renderTagSuggestions(); + if (tagModal.composing) return; + tagTimer = setTimeout(function () { + tagTimer = null; + if (!tagModal) return; + tagModal.waiting = false; reconcileTagSuggestions(); + }, 150); + } + if (managementEnabled) { + tagInput.addEventListener("input", debounceTags); + tagInput.addEventListener("compositionstart", function () { tagModal.composing = true; debounceTags(); }); + tagInput.addEventListener("compositionend", function () { tagModal.composing = false; debounceTags(); }); + tagInput.addEventListener("keydown", function (event) { + if (event.isComposing || !tagModal || tagModal.composing) return; + if (event.key === "Enter") { event.preventDefault(); submitTag(tagModal.active); } + if (!["ArrowDown", "ArrowUp"].includes(event.key) || tagModal.waiting || tagModal.pending) return; + event.preventDefault(); + var choices = tagModal.choices; + for (var step = 1; step <= choices.length; step += 1) { + var index = (tagModal.active + (event.key === "ArrowDown" ? step : -step) + choices.length) % choices.length; + if (!choices[index].added) { tagModal.active = index; break; } + } + renderTagSuggestions(); + }); + tagDialog.addEventListener("keydown", function (event) { + if (event.key !== "Tab") return; + var cancel = document.getElementById("tag-cancel"); + if (tagInput.disabled || (event.shiftKey && document.activeElement === tagInput)) { + event.preventDefault(); cancel.focus(); + } else if (!event.shiftKey && document.activeElement === cancel) { + event.preventDefault(); tagInput.focus(); + } + }); + tagDialog.addEventListener("cancel", function (event) { event.preventDefault(); closeTagDialog(); }); + document.getElementById("tag-cancel").addEventListener("click", closeTagDialog); + document.getElementById("tag-filter-search").addEventListener("input", function () { renderTagFilters(search.value.trim().toLowerCase()); }); + document.getElementById("tag-untagged").addEventListener("change", function (event) { + untagged = event.target.checked; selectedTags.clear(); applyFilters(); syncUrl(); + }); + document.querySelectorAll("[data-clear-tags]").forEach(function (button) { button.addEventListener("click", clearTags); }); + function closeTagFilter() { tagMenu.open = false; document.getElementById("tag-filter-summary").focus(); } + document.getElementById("tag-filter-done").addEventListener("click", closeTagFilter); + tagMenu.addEventListener("keydown", function (event) { if (event.key === "Escape") { event.preventDefault(); closeTagFilter(); } }); + document.addEventListener("click", function (event) { if (!tagMenu.contains(event.target)) tagMenu.open = false; }); + document.addEventListener("click", function (event) { + var chip = event.target.closest("[data-filter-tag]"); + if (chip) { event.stopPropagation(); chooseTag(chip.dataset.filterTag); } + }); + } + function relativeTime(value) { var timestamp = Date.parse(value); if (!Number.isFinite(timestamp)) return value || "Not recorded"; @@ -165,6 +411,7 @@ button.hidden = !visibleActions.includes(button.dataset.availabilityAction); }); + renderDetailTags(row); if (keepHistory && !changedSelection) return; if (managementEnabled) { loadVersions(row.dataset.draftId); @@ -273,7 +520,7 @@ var counts = updateCounts(); var visible = []; rows.forEach(function (row) { - var show = matches(row, query); + var show = matches(row, query) && matchesTags(row); row.hidden = !show; if (show) visible.push(row); }); @@ -293,15 +540,21 @@ populateSummary(visible[0]); } else { detail.classList.add("empty"); + selectedId = null; } } else { detail.classList.remove("empty"); } + renderTagFilters(query); } function applySort() { var direction = sort.value; rows.sort(function (left, right) { + if (direction === "tag") { + var a = rowTags(left)[0]; var b = rowTags(right)[0]; + return (!a - !b) || (a && b ? a.name.localeCompare(b.name) : 0) || left.dataset.title.localeCompare(right.dataset.title) || left.dataset.draftId.localeCompare(right.dataset.draftId); + } if (direction === "title") return left.dataset.title.localeCompare(right.dataset.title); if (direction === "versions") return Number(right.dataset.versionCount) - Number(left.dataset.versionCount); var delta = Date.parse(right.dataset.updated) - Date.parse(left.dataset.updated); @@ -324,6 +577,11 @@ var params = new URLSearchParams(); if (selectedId) params.set("draft", selectedId); params.set("view", view); + if (search.value) params.set("search", search.value); + if (repoFilter && repoFilter.value) params.set("repo", repoFilter.value); + if (sort.value !== "updated") params.set("sort", sort.value); + selectedTags.forEach(function (id) { params.append("tag", id); }); + if (untagged) params.set("untagged", "1"); history.replaceState(null, "", "/?" + params.toString()); } @@ -340,6 +598,7 @@ syncUrl(); }); row.addEventListener("keydown", function (event) { + if (event.target !== row) return; if (event.key === "Enter" || event.key === " ") { event.preventDefault(); populateSummary(row); @@ -365,6 +624,7 @@ all.value = ""; all.textContent = "All repositories"; repoFilter.append(all); + if (selected && !repositories.includes(selected)) repositories.push(selected); repositories.forEach(function (repository) { var option = document.createElement("option"); option.value = repository; @@ -380,11 +640,11 @@ syncUrl(); }); }); - search.addEventListener("input", applyFilters); - if (repoFilter) repoFilter.addEventListener("change", applyFilters); - sort.addEventListener("change", applySort); + search.addEventListener("input", function () { applyFilters(); syncUrl(); }); + if (repoFilter) repoFilter.addEventListener("change", function () { applyFilters(); syncUrl(); }); + sort.addEventListener("change", function () { applySort(); syncUrl(); }); document.addEventListener("keydown", function (event) { - if (event.key === "/" && document.activeElement !== search) { + if (event.key === "/" && !event.target.closest("input, textarea, select, dialog")) { event.preventDefault(); search.focus(); } @@ -768,7 +1028,6 @@ return; } refreshInFlight = true; - var previousSelection = selectedId; var query = selectedId ? "?selected=" + encodeURIComponent(selectedId) : ""; fetch("/api/dashboard/snapshot" + query, { headers: { Accept: "application/json" } }) .then(function (response) { @@ -779,19 +1038,28 @@ if (typeof snapshot.rows !== "string" || typeof snapshot.detail !== "string") { throw new Error("Dashboard snapshot was malformed"); } + if (managementEnabled) catalogue = snapshot.tags || []; + var focused = document.activeElement; + var detailHadFocus = document.getElementById("draft-detail").contains(focused); + var focusId = focused && focused.id; tbody.innerHTML = snapshot.rows; var detail = document.getElementById("draft-detail"); detail.outerHTML = snapshot.detail; rows = Array.from(tbody.querySelectorAll(".draft-row")); rows.forEach(bindRow); syncRepositoryFilter(); + reconcileTagSuggestions(); bindDetailActions(); applySort(); var selected = findRow(selectedId); - if (selected && selectedId === previousSelection && !selected.hidden) { + if (selected && !selected.hidden) { populateSummary(selected); } + if (detailHadFocus && !tagModal) { + var focusTarget = focusId && document.getElementById(focusId); + (selected && !selected.hidden && focusTarget ? focusTarget : search).focus(); + } syncUrl(); scheduleWake(); }) @@ -817,6 +1085,17 @@ // lives in another tab wins over the view parameter. var params = new URLSearchParams(location.search); + search.value = params.get("search") || ""; + if (repoFilter && params.get("repo")) { + var repositoryOption = document.createElement("option"); + repositoryOption.value = params.get("repo"); repositoryOption.textContent = params.get("repo"); + repoFilter.append(repositoryOption); repoFilter.value = params.get("repo"); + } + if (["updated", "oldest", "title", "versions", "tag"].includes(params.get("sort"))) sort.value = params.get("sort"); + if (managementEnabled) { + untagged = params.get("untagged") === "1"; + if (!untagged) selectedTags = new Set(params.getAll("tag")); + } var linkedRow = params.get("draft") ? findRow(params.get("draft")) : null; var linkedView = params.get("view") || "active"; if (linkedRow) linkedView = linkedRow.dataset.availability; @@ -826,5 +1105,6 @@ populateSummary(linkedRow); linkedRow.scrollIntoView({ block: "nearest" }); } + syncUrl(); scheduleWake(); })(); diff --git a/crates/keryx-render/src/lib.rs b/crates/keryx-render/src/lib.rs index 8e7a6e4..da807e8 100644 --- a/crates/keryx-render/src/lib.rs +++ b/crates/keryx-render/src/lib.rs @@ -7,7 +7,7 @@ pub mod pdf; use std::collections::BTreeSet; -use keryx_core::types::{Availability, DraftSummary}; +use keryx_core::types::{Availability, DashboardDraft, DraftSummary, Tag}; const DASHBOARD_CSS: &str = include_str!("../assets/dashboard.css"); const DASHBOARD_JS: &str = include_str!("../assets/dashboard.js"); @@ -32,7 +32,13 @@ fn display_or(value: Option<&str>, fallback: &str) -> String { escape_html(value.unwrap_or(fallback)) } -fn render_row(draft: &DraftSummary, selected: bool, management_enabled: bool) -> String { +fn render_row(row: &DashboardDraft, selected: bool, management_enabled: bool) -> String { + let draft = &row.summary; + let tags = if management_enabled { + row.tags.as_slice() + } else { + &[] + }; let availability = draft.availability(); let repository = management_enabled.then(|| repository(draft)).flatten(); let repository_label = if management_enabled { @@ -85,8 +91,8 @@ fn render_row(draft: &DraftSummary, selected: bool, management_enabled: bool) -> }; format!( - r#" -
{description_display}
+ r#" +
{description_display}
{tag_chips}
{repository}
{branch}
{updated}
v{version} · {version_count} {version_word}
@@ -147,16 +153,33 @@ fn render_row(draft: &DraftSummary, selected: bool, management_enabled: bool) -> availability = availability.as_str(), public_url = escape_html(&draft.public_url), raw_url = escape_html(&draft.raw_url), - search = escape_html(&search), + search = escape_html(&format!( + "{} {}", + search, + tags.iter() + .map(|t| t.name.as_str()) + .collect::>() + .join(" ") + )), + tag_data = if management_enabled { + format!( + " data-tags=\"{}\"", + escape_html(&serde_json::to_string(tags).expect("serializing tags")) + ) + } else { + String::new() + }, + tag_chips = render_tag_chips(tags, false), missing_class = if repository.is_none() { " missing" } else { "" }, protected_actions = protected_actions, ) } -pub fn render_dashboard_detail(draft: Option<&DraftSummary>, management_enabled: bool) -> String { - let Some(draft) = draft else { +pub fn render_dashboard_detail(row: Option<&DashboardDraft>, management_enabled: bool) -> String { + let Some(row) = row else { return r#""#.to_string(); }; + let draft = &row.summary; let availability = draft.availability(); let repository = if management_enabled { repository(draft).unwrap_or_else(|| "Provenance not recorded".into()) @@ -211,6 +234,7 @@ pub fn render_dashboard_detail(draft: Option<&DraftSummary>, management_enabled:
{detail_actions}
+ {tag_editor}
Origin
{repo_host}
Repository
{repository}
@@ -229,6 +253,14 @@ pub fn render_dashboard_detail(draft: Option<&DraftSummary>, management_enabled: title = escape_html(&draft.title), description = display_or(draft.description.as_deref(), "No description supplied."), detail_actions = detail_actions, + tag_editor = if management_enabled { + format!( + r#"

Tags

{}
"#, + render_tag_chips(&row.tags, true) + ) + } else { + String::new() + }, id = escape_html(&draft.draft_id), repo_host = if management_enabled { display_or(draft.repo_host.as_deref(), "Not recorded") @@ -249,6 +281,35 @@ pub fn render_dashboard_detail(draft: Option<&DraftSummary>, management_enabled: ) } +fn render_tag_chips(tags: &[Tag], removable: bool) -> String { + if tags.is_empty() { + return String::new(); + } + let limit = if removable { tags.len() } else { 2 }; + let mut html = String::from(""); + for tag in tags.iter().take(limit) { + let action = if removable { + "remove-tag" + } else { + "filter-tag" + }; + let label = if removable { + format!("Remove tag {}", tag.name) + } else { + format!("Filter by {}", tag.name) + }; + html.push_str(&format!(r#""#, escape_html(&tag.id), escape_html(&label), escape_html(&tag.name), if removable { " ×" } else { "" })); + } + if tags.len() > limit { + html.push_str(&format!( + "+{}", + tags.len() - limit + )); + } + html.push_str(""); + html +} + fn status_label(availability: Availability) -> &'static str { match availability { Availability::Active => "Active", @@ -258,7 +319,7 @@ fn status_label(availability: Availability) -> &'static str { } pub fn render_dashboard_rows( - drafts: &[DraftSummary], + drafts: &[DashboardDraft], selected_id: Option<&str>, management_enabled: bool, ) -> String { @@ -267,7 +328,7 @@ pub fn render_dashboard_rows( .map(|draft| { render_row( draft, - Some(draft.draft_id.as_str()) == selected_id, + Some(draft.summary.draft_id.as_str()) == selected_id, management_enabled, ) }) @@ -275,20 +336,21 @@ pub fn render_dashboard_rows( } pub fn render_dashboard( - drafts: &[DraftSummary], + drafts: &[DashboardDraft], + catalogue: &[Tag], _base_url: &str, management_enabled: bool, ) -> String { // The dashboard opens on Active, so the first active draft is selected. let first_active = drafts .iter() - .find(|draft| draft.availability() == Availability::Active); - let selected_id = first_active.map(|draft| draft.draft_id.as_str()); + .find(|draft| draft.summary.availability() == Availability::Active); + let selected_id = first_active.map(|draft| draft.summary.draft_id.as_str()); let rows = render_dashboard_rows(drafts, selected_id, management_enabled); let repositories = if management_enabled { drafts .iter() - .filter_map(repository) + .filter_map(|draft| repository(&draft.summary)) .collect::>() .into_iter() .map(|repository| { @@ -304,7 +366,7 @@ pub fn render_dashboard( let count_of = |state: Availability| { drafts .iter() - .filter(|draft| draft.availability() == state) + .filter(|draft| draft.summary.availability() == state) .count() }; let active_count = count_of(Availability::Active); @@ -387,8 +449,10 @@ pub fn render_dashboard(
{repo_filter} - + {tag_filter} + + {tag_selections}
@@ -399,6 +463,7 @@ pub fn render_dashboard(
+ {tag_modal}
!
@@ -429,6 +494,29 @@ pub fn render_dashboard( "#, + tag_filter = if management_enabled { + r#"
Tags: all

Match any selected tag

"# + } else { + "" + }, + tag_sort = if management_enabled { + r#""# + } else { + "" + }, + tag_selections = if management_enabled { + r#"
"# + } else { + "" + }, + tag_modal = if management_enabled { + format!( + r#"

Add tag

↑ ↓ to navigate · Enter to select · Esc to close

"#, + escape_html(&serde_json::to_string(catalogue).expect("serializing catalogue")) + ) + } else { + String::new() + }, css = DASHBOARD_CSS, js = DASHBOARD_JS, theme_color = "#2563eb", @@ -478,9 +566,37 @@ mod tests { } } + #[test] + fn dashboard_escapes_tags_and_redacts_all_tag_data_when_protected() { + let tag = Tag { + id: "tag-unique-secret".into(), + name: "\"".into(), + }; + let rows = [DashboardDraft { + summary: draft(), + tags: vec![tag.clone()], + }]; + let html = render_dashboard(&rows, std::slice::from_ref(&tag), "", true); + assert!(!html.contains(">(), + &[], + "https://keryx.test", + true, + ); assert!(html.contains("data-view=\"active\" aria-selected=\"true\"")); assert!(html.contains("data-view=\"snoozed\" aria-selected=\"false\"")); @@ -547,7 +674,15 @@ mod tests { #[test] fn protected_dashboard_redacts_provenance_and_omits_authenticated_actions() { - let html = render_dashboard(&[draft()], "https://keryx.test", false); + let html = render_dashboard( + &[DashboardDraft { + summary: draft(), + tags: vec![], + }], + &[], + "https://keryx.test", + false, + ); assert!(html.contains("Public view · management via authenticated CLI")); assert!(html.contains("Use authenticated CLI")); diff --git a/crates/keryx-server/Cargo.toml b/crates/keryx-server/Cargo.toml index 18c19c2..580c62d 100644 --- a/crates/keryx-server/Cargo.toml +++ b/crates/keryx-server/Cargo.toml @@ -31,6 +31,7 @@ url.workspace = true web-push-native.workspace = true [dev-dependencies] +rustls.workspace = true async-trait.workspace = true keryx-db = { workspace = true, features = ["test-support"] } keryx-store = { workspace = true, features = ["test-support"] } diff --git a/crates/keryx-server/src/lib.rs b/crates/keryx-server/src/lib.rs index acae68a..af9d369 100644 --- a/crates/keryx-server/src/lib.rs +++ b/crates/keryx-server/src/lib.rs @@ -26,11 +26,12 @@ use crate::notifications::{PushHub, VapidIdentity}; use crate::realtime::DashboardUpdates; use keryx_core::ids::new_internal_id; use keryx_core::types::{ - Availability, AvailabilityUpdate, DraftDetail, DraftSummary, PushSubscriptionInput, - UploadMetadata, UploadResponse, + Availability, AvailabilityUpdate, DashboardDraft, DraftDetail, DraftSummary, + PushSubscriptionInput, UploadMetadata, UploadResponse, }; use keryx_db::{ - AvailabilityError, DatabaseConfig, DraftStore, NewUpload, SeaOrmStore, UploadError, + AvailabilityError, DashboardTags, DatabaseConfig, DraftStore, NewUpload, SeaOrmStore, TagError, + UploadError, }; use keryx_policy::{validate_html, PolicyOptions, DEFAULT_MAX_HTML_BYTES}; use keryx_render::pdf::{render_version_pdf, PdfIdentity}; @@ -400,6 +401,11 @@ fn build_router(state: SharedState, max_html_bytes: usize) -> Router { .route("/", get(dashboard)) .route("/api/dashboard/events", get(dashboard_events)) .route("/api/dashboard/snapshot", get(dashboard_snapshot)) + .route("/api/dashboard/drafts/{draft_id}/tags", post(attach_tag)) + .route( + "/api/dashboard/drafts/{draft_id}/tags/{tag_id}", + delete(detach_tag), + ) .route("/healthz", get(healthz)) .route("/api/me", get(me)) .route("/api/uploads", post(upload)) @@ -530,8 +536,9 @@ async fn dashboard(State(state): State, headers: HeaderMap) -> Resp let base = base_url(&state, &headers); let drafts = dashboard_drafts(&state, &base).await; match drafts { - Ok(drafts) => Html(render_dashboard( + Ok((drafts, tags)) => Html(render_dashboard( &drafts, + &tags.catalogue, &base, state.api_key_hash.is_none(), )) @@ -540,12 +547,33 @@ async fn dashboard(State(state): State, headers: HeaderMap) -> Resp } } -async fn dashboard_drafts(state: &AppState, base: &str) -> Result> { +async fn dashboard_drafts( + state: &AppState, + base: &str, +) -> Result<(Vec, DashboardTags)> { let mut drafts = state.db.list_drafts().await?; for draft in &mut drafts { fill_urls(draft, base); } - Ok(drafts) + let mut tags = if state.api_key_hash.is_none() { + state.db.dashboard_tags().await? + } else { + DashboardTags::default() + }; + let rows = drafts + .into_iter() + .map(|summary| { + let assigned = tags + .assignments + .remove(&summary.draft_id) + .unwrap_or_default(); + DashboardDraft { + summary, + tags: assigned, + } + }) + .collect(); + Ok((rows, tags)) } #[derive(Deserialize, Default)] @@ -561,32 +589,95 @@ async fn dashboard_snapshot( headers: HeaderMap, ) -> Response { let base = base_url(&state, &headers); - let drafts = match dashboard_drafts(&state, &base).await { + let (drafts, tags) = match dashboard_drafts(&state, &base).await { Ok(drafts) => drafts, Err(error) => return internal_error(error), }; let selected = query .selected .as_deref() - .and_then(|selected| drafts.iter().find(|draft| draft.draft_id == selected)) + .and_then(|selected| { + drafts + .iter() + .find(|draft| draft.summary.draft_id == selected) + }) .or_else(|| { drafts .iter() - .find(|draft| draft.availability() == Availability::Active) + .find(|draft| draft.summary.availability() == Availability::Active) }) .or_else(|| drafts.first()); let management_enabled = state.api_key_hash.is_none(); - Json(json!({ + let mut snapshot = json!({ "ok": true, "rows": render_dashboard_rows( &drafts, - selected.map(|draft| draft.draft_id.as_str()), + selected.map(|draft| draft.summary.draft_id.as_str()), management_enabled, ), "detail": render_dashboard_detail(selected, management_enabled), - })) - .into_response() + }); + if management_enabled { + snapshot["tags"] = json!(tags.catalogue); + } + Json(snapshot).into_response() +} + +#[derive(Deserialize)] +struct AttachTag { + name: String, +} + +async fn attach_tag( + State(state): State, + Path(draft_id): Path, + headers: HeaderMap, + input: Result, JsonRejection>, +) -> Response { + if !authorized(&state, &headers) { + return unauthorized(); + } + let Ok(Json(input)) = input else { + return json_error(StatusCode::BAD_REQUEST, "A tag name is required."); + }; + let name = match keryx_db::canonical_tag_name(&input.name) { + Ok(name) => name, + Err(error) => return json_error(StatusCode::BAD_REQUEST, &error.to_string()), + }; + let result = state.db.attach_tag(&draft_id, &name).await; + tag_response(&state, result, Some(&name)) +} + +async fn detach_tag( + State(state): State, + Path((draft_id, tag_id)): Path<(String, String)>, + headers: HeaderMap, +) -> Response { + if !authorized(&state, &headers) { + return unauthorized(); + } + tag_response(&state, state.db.detach_tag(&draft_id, &tag_id).await, None) +} + +fn tag_response( + state: &AppState, + result: std::result::Result, TagError>, + attached_name: Option<&str>, +) -> Response { + match result { + Ok(tags) => { + state.dashboard_updates.changed(); + let mut body = json!({ "ok": true, "tags": tags }); + if let Some(name) = attached_name { + body["tag"] = json!(tags.iter().find(|tag| tag.name == name)); + } + Json(body).into_response() + } + Err(TagError::DraftNotFound) => json_error(StatusCode::NOT_FOUND, "Draft not found."), + Err(TagError::Invalid(message)) => json_error(StatusCode::BAD_REQUEST, &message), + Err(TagError::Other(error)) => internal_error(error.into()), + } } /// Stream coalesced invalidations. Each connection immediately receives the @@ -1280,6 +1371,142 @@ mod tests { (state, db) } + #[tokio::test] + async fn tag_routes_authorize_redact_and_leave_content_unchanged() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let (state, db) = test_state_and_db(keryx_store::memory_backend()).await; + let content = "Original

Exact bytes

"; + let upload = record(&state, content, None, &UploadMetadata::default()).await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let app = build_router(state.clone(), DEFAULT_MAX_HTML_BYTES); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = reqwest::Client::new(); + let url = format!("{base}/api/dashboard/drafts/{}/tags", upload.draft_id); + let mut updates = state.dashboard_updates.subscribe(); + assert_eq!( + client + .post(&url) + .json(&json!({"name": "private-label"})) + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + assert!(!updates.has_changed().unwrap()); + let response = client + .post(&url) + .bearer_auth("secret") + .json(&json!({"name": " Private-Label "})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!(body["tags"][0]["name"], "private-label"); + let tag_id = body["tags"][0]["id"].as_str().unwrap(); + assert!(updates.has_changed().unwrap()); + updates.borrow_and_update(); + for route in ["/", "/api/dashboard/snapshot"] { + let response = client + .get(format!("{base}{route}")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(!response.contains("private-label")); + assert!(!response.contains(tag_id)); + } + let snapshot: serde_json::Value = client + .get(format!("{base}/api/dashboard/snapshot")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(snapshot.get("tags").is_none()); + assert!(!snapshot["rows"].as_str().unwrap().contains("data-tags=")); + assert!(!snapshot["detail"] + .as_str() + .unwrap() + .contains("id=\"add-tag\"")); + assert_eq!( + client + .post(&url) + .bearer_auth("secret") + .json(&json!({"name": ""})) + .send() + .await + .unwrap() + .status(), + StatusCode::BAD_REQUEST + ); + assert!(!updates.has_changed().unwrap()); + assert_eq!( + client + .post(&url) + .bearer_auth("secret") + .json(&json!({})) + .send() + .await + .unwrap() + .status(), + StatusCode::BAD_REQUEST + ); + let deletion = format!("{url}/{tag_id}"); + assert_eq!( + client.delete(&deletion).send().await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!( + client + .delete(&deletion) + .bearer_auth("secret") + .send() + .await + .unwrap() + .status(), + StatusCode::OK + ); + assert_eq!( + client + .delete(&deletion) + .bearer_auth("secret") + .send() + .await + .unwrap() + .status(), + StatusCode::OK + ); + assert_eq!( + client + .post(format!("{base}/api/dashboard/drafts/missing/tags")) + .bearer_auth("secret") + .json(&json!({"name": "valid"})) + .send() + .await + .unwrap() + .status(), + StatusCode::NOT_FOUND + ); + let served = db + .find_public_version(&upload.draft_id, None) + .await + .unwrap() + .unwrap(); + assert_eq!( + state.store.get(&served.object_key).await.unwrap().unwrap(), + content + ); + server.abort(); + } + #[test] fn csp_tracks_the_upload_policy() { let strict = draft_csp(&PolicyOptions::default()); diff --git a/tests/dashboard/README.md b/tests/dashboard/README.md new file mode 100644 index 0000000..2e6954f --- /dev/null +++ b/tests/dashboard/README.md @@ -0,0 +1,8 @@ +Run from the repository root after installing the website's locked development dependencies: + +```sh +cargo build --offline +pnpm --dir website exec playwright test --config ../tests/dashboard/playwright.config.mjs +``` + +The runner starts its own server on port 17823 with a temporary SQLite database, blob directory and empty config. It rejects an already-running server and removes its temporary data on shutdown. Playwright Chromium must already be installed. diff --git a/tests/dashboard/playwright.config.mjs b/tests/dashboard/playwright.config.mjs new file mode 100644 index 0000000..e015c31 --- /dev/null +++ b/tests/dashboard/playwright.config.mjs @@ -0,0 +1,10 @@ +import { defineConfig } from '../../website/node_modules/@playwright/test/index.mjs'; +export default defineConfig({ + testDir: '.', testMatch: '*.spec.mjs', workers: 1, + outputDir: '/tmp/keryx-tags-browser-results', + use: { baseURL: 'http://127.0.0.1:17823', serviceWorkers: 'block', launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE } }, + webServer: { + command: 'node tests/dashboard/server.mjs', cwd: new URL('../..', import.meta.url).pathname, + url: 'http://127.0.0.1:17823/healthz', reuseExistingServer: false, + }, +}); diff --git a/tests/dashboard/server.mjs b/tests/dashboard/server.mjs new file mode 100644 index 0000000..a4cad46 --- /dev/null +++ b/tests/dashboard/server.mjs @@ -0,0 +1,12 @@ +// An isolated server fixture. Never inherit deployment configuration or paths. +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +const directory = await mkdtemp(join(tmpdir(), 'keryx-tags-browser-')); +const config = join(directory, 'config.toml'); +await writeFile(config, ''); +const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('KERYX_'))); +const server = spawn('target/debug/keryx', ['--config', config, 'serve', '--port', '17823', '--host', '127.0.0.1', '--storage', 'disk', '--db', join(directory, 'test.db'), '--data-dir', directory], { env, stdio: 'inherit' }); +for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => server.kill(signal)); +server.on('exit', async code => { await rm(directory, { recursive: true, force: true }); process.exit(code || 0); }); diff --git a/tests/dashboard/tags.spec.mjs b/tests/dashboard/tags.spec.mjs new file mode 100644 index 0000000..7bc3fcd --- /dev/null +++ b/tests/dashboard/tags.spec.mjs @@ -0,0 +1,237 @@ +import { test, expect } from '../../website/node_modules/@playwright/test/index.mjs'; + +async function upload(request, title, repo = 'one') { + const response = await request.post('/api/uploads', { data: { + html: `${title}

${title}

`, + metadata: { repoOrg: 'test', repoName: repo }, + } }); + expect(response.ok()).toBeTruthy(); + return (await response.json()).draftId; +} +async function attach(request, draft, name) { + const response = await request.post(`/api/dashboard/drafts/${draft}/tags`, { data: { name } }); + expect(response.ok()).toBeTruthy(); + return (await response.json()).tags.find(tag => tag.name === name); +} +async function seed(request) { + const a = await upload(request, 'Alpha'); + const b = await upload(request, 'Beta', 'two'); + const c = await upload(request, 'Gamma'); + const planning = await attach(request, a, 'planning'); + const review = await attach(request, b, 'review'); + await attach(request, b, 'planning'); + return { a, b, c, planning, review }; +} +async function controlledRefresh(page) { + await page.addInitScript(() => { + window.EventSource = class { addEventListener(_, listener) { window.refreshTestDashboard = listener; } }; + }); +} +async function refresh(page) { + const response = page.waitForResponse('**/api/dashboard/snapshot*'); + await page.evaluate(() => window.refreshTestDashboard()); + await response; +} +const visibleRows = page => page.locator('.draft-row:visible'); + +test.beforeEach(async ({ request }) => { + const response = await request.get('/api/drafts'); + const body = await response.json(); + for (const draft of body.drafts) await request.delete(`/api/drafts/${draft.draftId}?purge=true`); +}); + +test('ANY filters combine with repository, search and availability; URL and snapshots retain state', async ({ page, request }) => { + const { a, b, c, planning, review } = await seed(request); + await controlledRefresh(page); + await page.goto(`/?tag=${planning.id}&tag=${review.id}&draft=${a}&sort=tag`); + await expect(visibleRows(page)).toHaveCount(2); + await expect(page.locator('#tag-selections .tag-chip')).toHaveCount(3); + await page.locator('#repo-filter').selectOption('test/one'); + await expect(visibleRows(page)).toHaveCount(1); + await page.locator('#draft-search').fill('planning'); + await expect(visibleRows(page)).toHaveCount(1); + await refresh(page); + await expect(page.locator('#draft-search')).toHaveValue('planning'); + await expect(page.locator('#repo-filter')).toHaveValue('test/one'); + await expect(page.locator('#detail-id')).toHaveText(a); + await page.reload(); + await expect(visibleRows(page)).toHaveCount(1); + await page.locator('#tag-filter-summary').click(); + await expect(page.locator('#tag-filter-options label').filter({ hasText: /planning/ })).toContainText('(1)'); + await page.locator('#tag-filter-done').click(); + await page.locator('#draft-search').fill(''); + await page.locator('#repo-filter').selectOption(''); + await page.locator('#tag-filter-summary').click(); + await page.locator('#tag-untagged').check(); + await expect(visibleRows(page)).toHaveCount(1); + await expect(visibleRows(page)).toHaveAttribute('data-draft-id', c); + await page.locator(`[data-tag-choice="${review.id}"]`).check(); + await expect(page.locator('#tag-untagged')).not.toBeChecked(); + await expect(visibleRows(page)).toHaveCount(1); + await expect(visibleRows(page)).toHaveAttribute('data-draft-id', b); + await page.keyboard.press('Escape'); + await expect(page.locator('#tag-filter')).not.toHaveAttribute('open'); + await page.goto('/?tag=missing'); + await expect(visibleRows(page)).toHaveCount(0); + await refresh(page); + await expect(visibleRows(page)).toHaveCount(0); + await page.locator('#clear-empty-tags').click(); + await expect(visibleRows(page)).toHaveCount(3); + await request.put(`/api/drafts/${b}/availability`, { data: { state: 'disabled' } }); + await page.goto(`/?tag=${review.id}&view=active`); + await expect(visibleRows(page)).toHaveCount(0); + await page.getByRole('tab', { name: /Disabled/ }).click(); + await expect(visibleRows(page)).toHaveCount(1); +}); + +test('modal debounce blocks stale selection, handles IME, ranks matches and cancels cleanly', async ({ page, request }) => { + const { a, b } = await seed(request); + await attach(request, b, 'reviewed'); + await attach(request, b, 'needs-review'); + await controlledRefresh(page); + await page.goto(`/?draft=${a}`); + await page.clock.install(); + await page.clock.pauseAt(new Date()); + let writes = 0; + page.on('request', r => { if (r.method() === 'POST' && r.url().endsWith('/tags')) writes++; }); + await page.getByRole('button', { name: 'Add tag', exact: true }).click(); + await expect(page.locator('#tag-input')).toBeFocused(); + await page.locator('#tag-input').fill('review'); + await page.keyboard.press('Enter'); + await page.clock.runFor(149); + await expect(page.locator('#tag-suggestions [role=option]')).toHaveCount(0); + expect(writes).toBe(0); + await page.clock.runFor(1); + await expect(page.locator('#tag-suggestions [role=option]').first()).toHaveText('review'); + await expect(page.locator('#tag-suggestions [role=option]').nth(1)).toHaveText('reviewed'); + await expect(page.locator('#tag-suggestions [role=option]').nth(2)).toHaveText('needs-review'); + await expect(page.locator('#tag-suggestions')).not.toContainText('Create'); + await page.locator('#tag-input').dispatchEvent('compositionstart'); + await page.locator('#tag-input').fill('planning'); + await page.clock.runFor(300); + await expect(page.locator('#tag-suggestions [role=option]')).toHaveCount(0); + await page.locator('#tag-input').dispatchEvent('compositionend'); + await page.clock.runFor(150); + await expect(page.locator('#tag-suggestions [role=option]')).toHaveText('planning · Added'); + await page.keyboard.press('Enter'); + expect(writes).toBe(0); + await page.locator('#tag-input').fill('cancelled'); + await page.keyboard.press('Escape'); + await page.clock.runFor(150); + await expect(page.locator('#tag-dialog')).not.toBeVisible(); + await expect(page.locator('#add-tag')).toBeFocused(); + expect(writes).toBe(0); +}); + +test('save failures retain input; refresh preserves modal target; late responses cannot close a new modal', async ({ page, request }) => { + const { a, b } = await seed(request); + await controlledRefresh(page); + await page.goto(`/?draft=${a}`); + await page.locator('#add-tag').click(); + await page.locator('#tag-input').fill('new-tag'); + await expect(page.locator('#tag-suggestions')).toContainText('Create'); + await refresh(page); + await expect(page.locator('#tag-input')).toHaveValue('new-tag'); + await expect(page.locator('#tag-dialog')).toBeVisible(); + await page.route('**/api/dashboard/drafts/*/tags', route => route.fulfill({ status: 500, json: { error: 'Save failed for test' } })); + await page.keyboard.press('Enter'); + await expect(page.locator('#tag-error')).toHaveText('Save failed for test'); + await expect(page.locator('#tag-input')).toHaveValue('new-tag'); + await refresh(page); + await expect(page.locator('#tag-error')).toHaveText('Save failed for test'); + await page.unroute('**/api/dashboard/drafts/*/tags'); + let release; + const gate = new Promise(resolve => { release = resolve; }); + let savedTarget; + await page.route('**/api/dashboard/drafts/*/tags', async route => { + savedTarget = route.request().url(); + await gate; await route.continue(); + }); + await page.keyboard.press('Enter'); + await expect(page.locator('#tag-status')).toHaveText('Saving…'); + await page.locator('#tag-cancel').click(); + await page.locator(`.draft-row[data-draft-id="${b}"]`).click(); + await page.locator('#add-tag').click(); + await page.locator('#tag-input').fill('another'); + release(); + await expect.poll(() => savedTarget).toContain(a); + await expect(page.locator('#tag-input')).toHaveValue('another'); + await expect.poll(async () => (await (await request.get('/api/dashboard/snapshot')).json()).rows).toContain('new-tag'); + await refresh(page); + await expect(page.locator('#tag-input')).toHaveValue('another'); + await expect(page.locator('#tag-target')).toHaveText('To Beta'); + await request.delete(`/api/drafts/${b}?purge=true`); + await refresh(page); + await expect(page.locator('#tag-dialog')).not.toBeVisible(); + await expect(page.locator('#toast')).toContainText('no longer available'); +}); + +test('create, attach and remove work across live refreshes in mobile dark and light themes', async ({ page, request }) => { + const { a, review } = await seed(request); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`/?draft=${a}`); + await page.locator('#theme-select').selectOption('dark'); + await page.locator('#tag-filter-summary').click(); + const menu = await page.locator('.tag-filter-menu').boundingBox(); + expect(menu.x).toBeGreaterThanOrEqual(0); + expect(menu.x + menu.width).toBeLessThanOrEqual(390); + await page.locator('#tag-filter-search').fill('review'); + await page.locator(`[data-tag-choice="${review.id}"]`).check(); + await expect(visibleRows(page)).toHaveCount(1); + await page.locator('#tag-filter [data-clear-tags]').click(); + await page.locator('#tag-filter-done').click(); + await page.locator(`.draft-row[data-draft-id="${a}"]`).click(); + await page.locator('#add-tag').click(); + await page.locator('#tag-input').fill('review'); + await expect(page.locator('#tag-suggestions [role=option]').first()).toHaveText('review'); + await page.screenshot({ path: '/tmp/keryx-tags-dark-mobile.png', fullPage: true }); + await page.keyboard.press('Enter'); + await expect(page.locator('#tag-dialog')).not.toBeVisible(); + await expect(page.locator('#detail-tags')).toContainText('review'); + await page.locator('#add-tag').click(); + await page.locator('#tag-input').fill('Release Notes'); + await expect(page.locator('#tag-suggestions')).toContainText('Create "release notes"'); + await page.keyboard.press('Enter'); + await expect(page.locator('#tag-dialog')).not.toBeVisible(); + await expect(page.locator('#detail-tags')).toContainText('release notes'); + await page.locator('#theme-select').selectOption('light'); + await page.screenshot({ path: '/tmp/keryx-tags-light-mobile.png', fullPage: true }); + await page.getByRole('button', { name: 'Remove tag review', exact: true }).click(); + await expect(page.locator('#detail-tags')).not.toContainText('review'); + await page.locator('#add-tag').click(); + await page.locator('#tag-input').fill('typing'); + await attach(request, a, 'external-change'); + await expect(page.locator('#detail-tags')).toContainText('external-change'); + await expect(page.locator('#tag-input')).toHaveValue('typing'); + await expect(page.locator('#tag-dialog')).toBeVisible(); + await page.keyboard.press('Escape'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy(); + await page.locator(`[data-filter-tag="${review.id}"]`).first().click(); + await expect(visibleRows(page)).toHaveCount(1); +}); + +test('tag sorting, row chips, failed removal and keyboard focus survive refresh', async ({ page, request }) => { + const { a, b, planning } = await seed(request); + await controlledRefresh(page); + await page.goto(`/?draft=${a}&sort=tag`); + await expect(visibleRows(page).locator('.draft-title')).toHaveText(['Alpha', 'Beta', 'Gamma']); + await page.route(`**/api/dashboard/drafts/${a}/tags/*`, route => route.fulfill({ status: 500, json: { error: 'Removal failed' } })); + await page.getByRole('button', { name: 'Remove tag planning', exact: true }).click(); + await expect(page.locator('#toast')).toHaveText('Removal failed'); + await expect(page.locator('#detail-tags')).toContainText('planning'); + await page.locator('#add-tag').focus(); + await refresh(page); + await expect(page.locator('#add-tag')).toBeFocused(); + await page.locator(`.draft-row[data-draft-id="${b}"] [data-filter-tag="${planning.id}"]`).focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#detail-id')).toHaveText(a); + await expect(visibleRows(page)).toHaveCount(2); + await expect(page.locator('#tag-selections')).toContainText('planning'); + await page.locator('#add-tag').click(); + await page.keyboard.press('Shift+Tab'); + await expect(page.locator('#tag-cancel')).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(page.locator('#tag-input')).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page.locator('#add-tag')).toBeFocused(); +}); From 135477dd26b1d73be53173c0df3ab346644b44ef Mon Sep 17 00:00:00 2001 From: Prom3theu5 Date: Tue, 22 Sep 2026 23:21:33 +0100 Subject: [PATCH 2/2] chore: bump version to 0.7.0 --- Cargo.lock | 20 ++++++++++---------- Cargo.toml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e9e6c65..58383de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3160,7 +3160,7 @@ dependencies = [ [[package]] name = "keryx" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "chrono", @@ -3185,7 +3185,7 @@ dependencies = [ [[package]] name = "keryx-client" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "dirs", @@ -3201,7 +3201,7 @@ dependencies = [ [[package]] name = "keryx-config" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "config", @@ -3213,7 +3213,7 @@ dependencies = [ [[package]] name = "keryx-core" -version = "0.6.0" +version = "0.7.0" dependencies = [ "chrono", "hex", @@ -3225,7 +3225,7 @@ dependencies = [ [[package]] name = "keryx-db" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "async-trait", @@ -3240,7 +3240,7 @@ dependencies = [ [[package]] name = "keryx-policy" -version = "0.6.0" +version = "0.7.0" dependencies = [ "scraper", "serde", @@ -3249,7 +3249,7 @@ dependencies = [ [[package]] name = "keryx-render" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -3264,7 +3264,7 @@ dependencies = [ [[package]] name = "keryx-server" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "async-trait", @@ -3292,7 +3292,7 @@ dependencies = [ [[package]] name = "keryx-share" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "docker_credential", @@ -3307,7 +3307,7 @@ dependencies = [ [[package]] name = "keryx-store" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index a61d060..374a61a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ default-members = [".", "crates/*"] resolver = "2" [workspace.package] -version = "0.6.0" +version = "0.7.0" edition = "2021" license = "MIT" repository = "https://github.com/SimCubeLtd/keryx"