diff --git a/rust/README.md b/rust/README.md index c40d5de..fc52603 100644 --- a/rust/README.md +++ b/rust/README.md @@ -133,7 +133,7 @@ signed-cookie sessions for dev. `Curator` RBAC guard. The curator dashboard | Variants | `/curator/variants` | CRUD; alias/coordinate JSONB editing | | Genome regions | `/curator/regions` | CRUD (coordinates/properties JSONB) | | Curation proposals | `/curator/proposals` | review/promote Navigator-submitted branch proposals → catalog | -| Publication candidates | `/curator/publications` | review OpenAlex discoveries → promote to references | +| Publication candidates | `/curator/publications` | review OpenAlex discoveries → promote to references; status/search/sort filters, retract an accept back to rejected | | Change-sets | `/curator/change-sets` | tree-versioning lifecycle + diff + per-change review/apply | | Merge review | `/curator/reviews` | resolve SNP-graft flags / merge ambiguities via the `wip_*` staging tables (accept-anchor / reparent / merge / defer) | | Variant naming | `/curator/naming` | the **DU naming authority**: queue + mint `DUxxxxx` + lifecycle | diff --git a/rust/crates/du-db/src/publication.rs b/rust/crates/du-db/src/publication.rs index 1df245f..3457173 100644 --- a/rust/crates/du-db/src/publication.rs +++ b/rust/crates/du-db/src/publication.rs @@ -354,27 +354,86 @@ const CAND_COLS: &str = "id, openalex_id, doi, title, abstract AS abstract_text, journal_name, relevance_score::float8 AS relevance_score, cited_by_count, open_access_status, \ status, created_at"; -/// Paginated candidate queue, optionally filtered by status, newest first. +/// Queue ordering. The default is discovery order (newest first); the others let +/// a curator reach a paper without paging through the whole backlog. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum CandidateSort { + /// Discovery time, newest first. + #[default] + Newest, + /// Discovery time, oldest first (work the backlog from the bottom). + Oldest, + /// Publication date, newest first (undated last). + Published, + /// Title, A→Z. + Title, +} + +impl CandidateSort { + /// Parse a UI/query-string value; anything unrecognised falls back to the default. + pub fn parse(s: &str) -> Self { + match s { + "oldest" => Self::Oldest, + "published" => Self::Published, + "title" => Self::Title, + _ => Self::Newest, + } + } + + fn order_by(self) -> &'static str { + match self { + Self::Newest => "created_at DESC, id DESC", + Self::Oldest => "created_at ASC, id ASC", + Self::Published => "publication_date DESC NULLS LAST, id DESC", + Self::Title => "title ASC NULLS LAST, id DESC", + } + } +} + +/// What narrows the candidate review queue. +#[derive(Debug, Default, Clone)] +pub struct CandidateFilter<'a> { + /// `pending`/`accepted`/`rejected`/`deferred`; `None` (or empty) = every status. + pub status: Option<&'a str>, + /// Case-insensitive substring over title, journal, DOI and OpenAlex id. + pub q: Option<&'a str>, + pub sort: CandidateSort, +} + +/// Neutralise LIKE metacharacters so a curator's `%` searches for a literal `%`. +fn like_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_") +} + +/// Paginated candidate queue. pub async fn list_candidates( pool: &PgPool, - status: Option<&str>, + f: &CandidateFilter<'_>, page: i64, page_size: i64, ) -> Result, DbError> { let offset = Page::<()>::offset(page, page_size); let limit = page_size.clamp(1, 200); - let status = status.filter(|s| !s.is_empty()); - let where_sql = "WHERE ($1::text IS NULL OR status = $1)"; + let status = f.status.filter(|s| !s.is_empty()); + let q = f.q.map(str::trim).filter(|s| !s.is_empty()).map(like_escape); + let where_sql = "WHERE ($1::text IS NULL OR status = $1) \ + AND ($2::text IS NULL OR title ILIKE '%' || $2 || '%' \ + OR journal_name ILIKE '%' || $2 || '%' \ + OR doi ILIKE '%' || $2 || '%' \ + OR openalex_id ILIKE '%' || $2 || '%')"; let total: i64 = sqlx::query_scalar(&format!("SELECT count(*) FROM pubs.publication_candidate {where_sql}")) .bind(status) + .bind(q.as_deref()) .fetch_one(pool) .await?; let items: Vec = sqlx::query_as(&format!( "SELECT {CAND_COLS} FROM pubs.publication_candidate {where_sql} \ - ORDER BY created_at DESC, id DESC LIMIT $2 OFFSET $3" + ORDER BY {} LIMIT $3 OFFSET $4", + f.sort.order_by() )) .bind(status) + .bind(q.as_deref()) .bind(limit) .bind(offset) .fetch_all(pool) @@ -485,6 +544,84 @@ pub async fn promote_candidate(pool: &PgPool, id: i64, by: Uuid) -> Result Result { + Ok(sqlx::query_scalar(ATTACH_COUNT_SQL).bind(id.0).fetch_one(pool).await?) +} + +/// Outcome of walking an accepted candidate back to `rejected`. +#[derive(Debug, Clone, Default)] +pub struct Retraction { + /// The paper the accept had promoted to, if it still resolves. + pub publication_id: Option, + /// The paper was removed from the catalog as part of the retraction. + pub publication_deleted: bool, + /// Samples/studies hanging off that paper. Non-zero means deletion was + /// refused: real curated data references it, so only the candidate flips. + pub attached: i64, +} + +/// **Retract** an accepted candidate: flip it back to `rejected`, and optionally +/// remove the publication the accept promoted it to. +/// +/// The promotion may have *reused* a paper the curators already owned rather than +/// creating one, and samples or studies may have been attached since — so the +/// delete is opt-in (`delete_publication`) and is refused outright once anything +/// links to the paper. The candidate always ends up `rejected`; the returned +/// [`Retraction`] says what happened to the paper. +pub async fn retract_candidate( + pool: &PgPool, + id: i64, + by: Uuid, + delete_publication: bool, +) -> Result { + let mut tx = pool.begin().await?; + let c: Candidate = sqlx::query_as(&format!( + "SELECT {CAND_COLS} FROM pubs.publication_candidate WHERE id = $1 FOR UPDATE" + )) + .bind(id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| DbError::Conflict(format!("candidate {id} not found")))?; + + // Same match promote_candidate used to find/create the paper. + let pub_id: Option = sqlx::query_scalar( + "SELECT id FROM pubs.publication WHERE open_alex_id = $1 OR ($2::text IS NOT NULL AND doi = $2) \ + LIMIT 1 FOR UPDATE", + ) + .bind(&c.openalex_id) + .bind(c.doi.as_deref()) + .fetch_optional(&mut *tx) + .await?; + + let mut out = Retraction { publication_id: pub_id.map(PublicationId), ..Default::default() }; + if let Some(pid) = pub_id { + out.attached = + sqlx::query_scalar(ATTACH_COUNT_SQL).bind(pid).fetch_one(&mut *tx).await?; + if delete_publication && out.attached == 0 { + sqlx::query("DELETE FROM pubs.publication WHERE id = $1") + .bind(pid) + .execute(&mut *tx) + .await?; + out.publication_deleted = true; + out.publication_id = None; + } + } + + sqlx::query("UPDATE pubs.publication_candidate SET status = 'rejected', reviewed_by = $2 WHERE id = $1") + .bind(id) + .bind(by) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(out) +} + #[derive(sqlx::FromRow)] struct PublicationRow { id: i64, diff --git a/rust/crates/du-db/tests/publication_candidate.rs b/rust/crates/du-db/tests/publication_candidate.rs index 152de4b..0e9d7b8 100644 --- a/rust/crates/du-db/tests/publication_candidate.rs +++ b/rust/crates/du-db/tests/publication_candidate.rs @@ -13,6 +13,11 @@ fn database_url() -> Option { } +/// Queue filter that only narrows by status — what most of these assertions want. +fn status_filter(status: Option<&str>) -> du_db::publication::CandidateFilter<'_> { + du_db::publication::CandidateFilter { status, ..Default::default() } +} + async fn test_user(pool: &PgPool) -> Uuid { sqlx::query_scalar( "INSERT INTO ident.users (handle, display_name) VALUES ('testpc-curator', 'Test Curator') \ @@ -57,11 +62,11 @@ async fn candidate_review_and_promote() { ).await.expect("upsert 2"); // Both are pending. - let pending = du_db::publication::list_candidates(&pool, Some("pending"), 1, 50).await.expect("list"); + let pending = du_db::publication::list_candidates(&pool, &status_filter(Some("pending")), 1, 50).await.expect("list"); let mine: Vec<_> = pending.items.iter().filter(|c| c.openalex_id.starts_with("TESTPC-")).collect(); assert_eq!(mine.len(), 2, "two pending candidates"); - let c1 = du_db::publication::list_candidates(&pool, Some("pending"), 1, 50) + let c1 = du_db::publication::list_candidates(&pool, &status_filter(Some("pending")), 1, 50) .await.unwrap().items.into_iter().find(|c| c.openalex_id == "TESTPC-W1").unwrap(); // Promote W1 → a real publication; candidate flips to accepted. @@ -84,19 +89,141 @@ async fn candidate_review_and_promote() { assert_eq!(dup, 1, "no duplicate publication"); // Reject W2. - let c2 = du_db::publication::list_candidates(&pool, None, 1, 50) + let c2 = du_db::publication::list_candidates(&pool, &status_filter(None), 1, 50) .await.unwrap().items.into_iter().find(|c| c.openalex_id == "TESTPC-W2").unwrap(); assert!(du_db::publication::review_candidate(&pool, c2.id, "rejected", curator).await.expect("reject")); let c2_after = du_db::publication::get_candidate(&pool, c2.id).await.unwrap().unwrap(); assert_eq!(c2_after.status, "rejected"); // Filter now shows one accepted, one rejected, zero pending (of ours). - let still_pending = du_db::publication::list_candidates(&pool, Some("pending"), 1, 50) + let still_pending = du_db::publication::list_candidates(&pool, &status_filter(Some("pending")), 1, 50) .await.unwrap().items.into_iter().filter(|c| c.openalex_id.starts_with("TESTPC-")).count(); assert_eq!(still_pending, 0, "no TESTPC pending left"); } +#[tokio::test] +async fn queue_search_and_sort() { + let Some(url) = database_url() else { + eprintln!("DATABASE_URL unset — skipping queue_search_and_sort test"); + return; + }; + let db = du_db::testing::ephemeral_db(&url).await.expect("ephemeral db"); + let pool = db.pool().clone(); + use du_db::publication::{CandidateFilter, CandidateSort}; + + for (oa, title, journal, date) in [ + ("TESTPC-S1", "Zebra haplogroups of the steppe", "J. Phylogenetics", "2025-01-02"), + ("TESTPC-S2", "Ancient mitochondrial lineages", "Nature 100% Genetics", "2026-03-04"), + ("TESTPC-S3", "Survey of Y-chromosome markers", "Cell", "2024-05-06"), + ] { + du_db::publication::upsert_candidate( + &pool, + &du_db::publication::NewCandidate { + openalex_id: oa, + title: Some(title), + journal_name: Some(journal), + publication_date: Some(date.parse().unwrap()), + ..Default::default() + }, + ).await.expect("upsert"); + } + + let search = |q: &'static str| CandidateFilter { q: Some(q), ..Default::default() }; + let ids = |p: du_db::Page| -> Vec { + p.items.into_iter().map(|c| c.openalex_id).collect() + }; + + // Title, journal and OpenAlex id all match, case-insensitively. + let r = du_db::publication::list_candidates(&pool, &search("zebra"), 1, 50).await.unwrap(); + assert_eq!(ids(r), vec!["TESTPC-S1"], "title match, case-insensitive"); + let r = du_db::publication::list_candidates(&pool, &search("cell"), 1, 50).await.unwrap(); + assert_eq!(ids(r), vec!["TESTPC-S3"], "journal match"); + let r = du_db::publication::list_candidates(&pool, &search("testpc-s2"), 1, 50).await.unwrap(); + assert_eq!(ids(r), vec!["TESTPC-S2"], "openalex id match"); + + // The count is filtered too, so the pager doesn't advertise phantom pages. + let r = du_db::publication::list_candidates(&pool, &search("TESTPC-S"), 1, 2).await.unwrap(); + assert_eq!(r.total, 3); + assert_eq!(r.total_pages(), 2); + + // `%` is a literal, not a wildcard: it only matches the journal that has one. + let r = du_db::publication::list_candidates(&pool, &search("100%"), 1, 50).await.unwrap(); + assert_eq!(ids(r), vec!["TESTPC-S2"], "LIKE metacharacters are escaped"); + + // Sorts. All three rows share a created_at, so sort on their own fields. + let sorted = |sort| CandidateFilter { q: Some("TESTPC-S"), sort, ..Default::default() }; + let r = du_db::publication::list_candidates(&pool, &sorted(CandidateSort::Published), 1, 50).await.unwrap(); + assert_eq!(ids(r), vec!["TESTPC-S2", "TESTPC-S1", "TESTPC-S3"], "publication date, newest first"); + let r = du_db::publication::list_candidates(&pool, &sorted(CandidateSort::Title), 1, 50).await.unwrap(); + assert_eq!(ids(r), vec!["TESTPC-S2", "TESTPC-S3", "TESTPC-S1"], "title A→Z"); + + assert_eq!(CandidateSort::parse("published"), CandidateSort::Published); + assert_eq!(CandidateSort::parse("nonsense"), CandidateSort::Newest, "unknown falls back"); +} + +#[tokio::test] +async fn retract_moves_accepted_back_to_rejected() { + let Some(url) = database_url() else { + eprintln!("DATABASE_URL unset — skipping retract test"); + return; + }; + let db = du_db::testing::ephemeral_db(&url).await.expect("ephemeral db"); + let pool = db.pool().clone(); + let curator = test_user(&pool).await; + + let new = |oa: &'static str| du_db::publication::NewCandidate { + openalex_id: oa, + title: Some("Retract me"), + ..Default::default() + }; + for oa in ["TESTPC-T1", "TESTPC-T2", "TESTPC-T3"] { + du_db::publication::upsert_candidate(&pool, &new(oa)).await.expect("upsert"); + } + async fn by_oa(pool: &PgPool, oa: &str) -> du_db::publication::Candidate { + du_db::publication::list_candidates(pool, &status_filter(None), 1, 200) + .await.unwrap().items.into_iter().find(|c| c.openalex_id == oa).unwrap() + } + + // 1. Retract without deleting: the paper stays in the catalog. + let t1 = by_oa(&pool, "TESTPC-T1").await; + let p1 = du_db::publication::promote_candidate(&pool, t1.id, curator).await.expect("promote"); + let r = du_db::publication::retract_candidate(&pool, t1.id, curator, false).await.expect("retract"); + assert_eq!(r.publication_id, Some(p1)); + assert!(!r.publication_deleted); + assert_eq!(du_db::publication::get_candidate(&pool, t1.id).await.unwrap().unwrap().status, "rejected"); + assert!(du_db::publication::get_by_id(&pool, p1).await.unwrap().is_some(), "paper kept"); + + // 2. Retract with delete: an untouched promoted paper goes away. + let t2 = by_oa(&pool, "TESTPC-T2").await; + let p2 = du_db::publication::promote_candidate(&pool, t2.id, curator).await.expect("promote"); + let r = du_db::publication::retract_candidate(&pool, t2.id, curator, true).await.expect("retract"); + assert!(r.publication_deleted, "unattached paper removed"); + assert_eq!(r.publication_id, None); + assert!(du_db::publication::get_by_id(&pool, p2).await.unwrap().is_none(), "paper gone"); + assert_eq!(du_db::publication::get_candidate(&pool, t2.id).await.unwrap().unwrap().status, "rejected"); + + // 3. Delete is refused once a study hangs off the paper — the candidate still + // flips, but the curated links survive. + let t3 = by_oa(&pool, "TESTPC-T3").await; + let p3 = du_db::publication::promote_candidate(&pool, t3.id, curator).await.expect("promote"); + let study = du_db::study::upsert_by_accession(&pool, "TESTPC-PRJEB1", du_db::study::source_for_accession("PRJEB1")) + .await.expect("study"); + du_db::study::link_publication(&pool, p3.0, study).await.expect("link"); + assert_eq!(du_db::publication::attachment_count(&pool, p3).await.unwrap(), 1); + let r = du_db::publication::retract_candidate(&pool, t3.id, curator, true).await.expect("retract"); + assert!(!r.publication_deleted, "attached paper is not deleted"); + assert_eq!(r.attached, 1); + assert!(du_db::publication::get_by_id(&pool, p3).await.unwrap().is_some(), "paper kept"); + assert_eq!(du_db::publication::get_candidate(&pool, t3.id).await.unwrap().unwrap().status, "rejected"); + + // Retracting again is idempotent — and the paper kept in step 1 is now free of + // attachments, so a second pass with the box ticked does remove it. + let r = du_db::publication::retract_candidate(&pool, t1.id, curator, true).await.expect("re-retract"); + assert!(r.publication_deleted, "second pass removes the paper left behind"); + assert!(du_db::publication::get_by_id(&pool, p1).await.unwrap().is_none()); +} + #[tokio::test] async fn bulk_system_reject_only_touches_pending() { let Some(url) = database_url() else { @@ -114,7 +241,7 @@ async fn bulk_system_reject_only_touches_pending() { &du_db::publication::NewCandidate { openalex_id: oa, title: Some("t"), ..Default::default() }, ).await.expect("upsert"); } - let r4 = du_db::publication::list_candidates(&pool, None, 1, 100) + let r4 = du_db::publication::list_candidates(&pool, &status_filter(None), 1, 100) .await.unwrap().items.into_iter().find(|c| c.openalex_id == "TESTPC-R4").unwrap(); du_db::publication::review_candidate(&pool, r4.id, "accepted", curator).await.unwrap(); diff --git a/rust/crates/du-web/src/routes/publications.rs b/rust/crates/du-web/src/routes/publications.rs index c96ab5c..849c614 100644 --- a/rust/crates/du-web/src/routes/publications.rs +++ b/rust/crates/du-web/src/routes/publications.rs @@ -1,8 +1,10 @@ //! Curator **publication-candidate review** UI. The publication-discovery job //! (OpenAlex) writes candidates into `pubs.publication_candidate`; curators -//! triage them here. Two-panel HTMX screen mirroring the proposals UI: a status- -//! filtered queue (left) and a review panel (right) with Accept (promote to a -//! real `pubs.publication`) / Reject / Defer. +//! triage them here. Two-panel HTMX screen mirroring the proposals UI: a +//! filtered queue (left — status, free-text search and sort, so a curator reaches +//! a paper without paging through the backlog) and a review panel (right) with +//! Accept (promote to a real `pubs.publication`) / Reject / Defer, plus Retract +//! to walk an accept back to rejected. use crate::auth::{Curator, NavUser}; use crate::error::AppError; @@ -116,6 +118,8 @@ struct Row { struct ListView { status: String, + q: String, + sort: String, rows: Vec, page: i64, total: i64, @@ -125,6 +129,10 @@ struct ListView { #[derive(Deserialize)] struct ListQuery { status: Option, + /// Free-text over title / journal / DOI / OpenAlex id. + q: Option, + /// `newest` (default) | `oldest` | `published` | `title`. + sort: Option, page: Option, } @@ -143,14 +151,23 @@ fn to_row(c: du_db::publication::Candidate) -> Row { } } -async fn load_list(st: &AppState, q: &ListQuery) -> Result { +async fn load_list(st: &AppState, query: &ListQuery) -> Result { // Default the queue to the pending items (the actionable ones). - let status = q.status.clone().unwrap_or_else(|| "pending".into()); - let filter = if status.is_empty() { None } else { Some(status.as_str()) }; - let result = du_db::publication::list_candidates(&st.pool, filter, q.page.unwrap_or(1), 20).await?; + let status = query.status.clone().unwrap_or_else(|| "pending".into()); + let q = query.q.clone().unwrap_or_default(); + let sort = query.sort.clone().unwrap_or_else(|| "newest".into()); + let filter = du_db::publication::CandidateFilter { + status: if status.is_empty() { None } else { Some(status.as_str()) }, + q: Some(q.as_str()), + sort: du_db::publication::CandidateSort::parse(&sort), + }; + let result = + du_db::publication::list_candidates(&st.pool, &filter, query.page.unwrap_or(1), 20).await?; let (page, total, total_pages) = (result.page, result.total, result.total_pages()); Ok(ListView { status, + q, + sort, rows: result.items.into_iter().map(to_row).collect(), page, total, @@ -200,6 +217,15 @@ async fn list( // ── detail / review panel ─────────────────────────────────────────────────── +/// The catalog paper an accepted candidate was promoted to. +struct PromotedView { + id: i64, + /// Reference-list search that lands on the paper. + url: String, + /// Samples/studies attached — non-zero blocks removing the paper on retract. + attached: i64, +} + struct DetailView { id: i64, title: String, @@ -213,6 +239,8 @@ struct DetailView { abstract_text: Option, /// Not yet accepted → the action buttons are live. can_act: bool, + /// Accepted → the promoted paper, for the retract/attach forms. + promoted: Option, notice: Option, } @@ -229,6 +257,22 @@ async fn build_detail(st: &AppState, id: i64, notice: Option) -> Result< .ok_or_else(|| AppError::NotFound(format!("candidate {id}")))?; let doi = c.doi.filter(|d| !d.trim().is_empty()); let doi_url = doi.as_ref().map(|d| format!("https://doi.org/{d}")); + let accepted = c.status == "accepted"; + // Only accepted candidates have a promoted paper to retract or attach to. + let mut promoted = None; + if accepted { + if let Some(pid) = du_db::publication::publication_for_candidate(&st.pool, id).await? { + let query = doi.clone().unwrap_or_else(|| c.title.clone().unwrap_or_default()); + promoted = Some(PromotedView { + id: pid.0, + url: format!( + "/references?query={}", + percent_encoding::utf8_percent_encode(&query, percent_encoding::NON_ALPHANUMERIC) + ), + attached: du_db::publication::attachment_count(&st.pool, pid).await?, + }); + } + } Ok(DetailView { id: c.id, title: c.title.unwrap_or_else(|| "(untitled)".into()), @@ -240,7 +284,8 @@ async fn build_detail(st: &AppState, id: i64, notice: Option) -> Result< relevance: c.relevance_score.map(|r| format!("{r:.2}")).unwrap_or_else(|| "—".into()), status: c.status.clone(), abstract_text: c.abstract_text.filter(|a| !a.trim().is_empty()), - can_act: c.status != "accepted", + can_act: !accepted, + promoted, notice, }) } @@ -266,8 +311,12 @@ async fn panel( #[derive(Deserialize)] struct ReviewForm { - /// accept | reject | defer + /// accept | reject | retract | defer action: String, + /// `retract` only: also delete the paper the accept promoted to. Checkbox — + /// present (`on`) when ticked, absent otherwise. + #[serde(default)] + delete_publication: Option, } async fn review( @@ -283,6 +332,24 @@ async fn review( Err(du_db::DbError::Conflict(msg)) => msg, Err(e) => return Err(e.into()), }, + // Undo an accept: back to rejected, optionally taking the promoted paper + // with it (refused when samples/studies already reference it). + "retract" => { + let r = du_db::publication::retract_candidate( + &st.pool, + id, + s.user_id, + f.delete_publication.is_some(), + ) + .await?; + let mut msg = locale.t.get("pc.notice.retracted").to_string(); + if r.publication_deleted { + msg = format!("{msg} {}", locale.t.get("pc.notice.pub_deleted")); + } else if f.delete_publication.is_some() && r.attached > 0 { + msg = format!("{msg} {} ({})", locale.t.get("pc.notice.pub_kept"), r.attached); + } + msg + } "reject" => { du_db::publication::review_candidate(&st.pool, id, "rejected", s.user_id).await?; locale.t.get("pc.notice.rejected").to_string() @@ -294,3 +361,80 @@ async fn review( }; changed_response(&st, locale.t, id, Some(notice)).await } + +#[cfg(test)] +mod tests { + use super::*; + use crate::i18n::Lang; + use askama::Template; + + fn t() -> T { + T::new(Lang::En) + } + + fn detail(status: &str, promoted: Option) -> DetailView { + DetailView { + id: 7, + title: "A paper".into(), + journal: "J. Phylogenetics".into(), + date: "2026-01-01".into(), + doi: None, + doi_url: None, + openalex_id: "W1".into(), + relevance: "—".into(), + status: status.into(), + abstract_text: None, + can_act: status != "accepted", + promoted, + notice: None, + } + } + + #[test] + fn filters_round_trip_into_the_form() { + let list = ListView { + status: "rejected".into(), + q: "ancient dna".into(), + sort: "published".into(), + rows: vec![], + page: 1, + total: 0, + total_pages: 0, + }; + let html = PageTemplate { t: t(), next: "/".into(), user: None, list }.render().unwrap(); + assert!(html.contains("value=\"ancient dna\""), "search box keeps the query"); + assert!(html.contains("