//! The Askama entry point for the discover screen's described search box. //! //! N8's second measured site, and the one the tag typeahead (`1ea614c8`) could //! not go with. Its candidates navigate: each one is a project, an item or a //! creator page, so picking a row leaves the screen rather than changing part //! of it. Until [`Action::navigates`] existed a pick could only be spelled //! `Action::get(url)`, which emits an `href` *and* an `hx-get` with no target, //! and htmx puts the whole document inside the row that was clicked. That is //! quasicoherent `00ee7af5`, and it is the whole of why this waited. //! //! # What is described here, and what was hand-written before //! //! Described: that the box owns a list, where the list comes from, that the //! value must stand still 200ms and carry two characters before the route is //! asked, that picking a row goes to the page it names, and separately that the //! same value re-reads the results under the current filters after 150ms. Every //! one of those was a literal in `static/page-discover.js` — the debounce, the //! `q.length < 2` guard, the fetch, the row markup, arrow-key movement, and the //! `` each row was built as by hand. //! //! Host: nothing. The last block of that file goes with this. //! //! # Two questions about one value //! //! This is the box [`Field::consults`] was made a `Vec` for. The suggestion //! list and the results are two questions about the same string, asked at two //! rates and answered in two places, and only one of them used to be sayable. //! The suggestion one is [`Field::suggests`], because its answer is this //! field's own list of candidates; the results one is an ordinary consult //! landing in the region every other filter control lands in. //! //! The filters ride with the results question and not with the suggestion one. //! A pick here goes to a page, so there is nothing for the current facets to be //! carried forward into — unlike the tag box, where a pick adds a facet to the //! set it was offered under. //! //! # What the described box does not carry //! //! Two attributes the hand-written markup had and the vocabulary has no word //! for, both deliberate rather than overlooked: //! //! - `hx-indicator="#search-spinner"`. Nothing says "while this call is out, //! show that". The spinner still fires for every other control on the screen. //! - `hx-trigger="…, search"`, the event a `type="search"` box fires when its //! clear affordance is pressed. A described field is a //! [`FieldKind::Text`], and clearing it by keyboard asks as it always did. //! //! [`Action::navigates`]: quasi_router::Action::navigates //! [`Field::consults`]: quasi_router::Field::consults //! [`Field::suggests`]: quasi_router::Field::suggests use makeover_layout::FieldKind; use quasi_router::{Action, Candidate, Consult, Field, Node}; use super::discover_typeahead::FILTERS; /// The field's name, and therefore the id of the box and the stem of its /// list's. /// /// `q` is what the search value has always been submitted under: it is what /// `/discover` and `/discover/results` read, what every other filter control /// sends along, and one of the eleven names a tag pick carries forward. So the /// described box takes the name the screen already had rather than a new one. pub const FIELD: &str = "q"; /// The region the results question's answer replaces. /// /// `/discover/results` is an Askama route the description layer does not serve, /// so it cannot name what it changed and the control has to. The same constant /// the tag box needs, for the same reason. const RESULTS: &str = "results-container"; /// What the second question asks: the results under the current filters. const RESULTS_ROUTE: &str = "/discover/results"; /// The route the box asks about what is being typed. const SUGGEST_ROUTE: &str = "/discover/suggestions"; /// How long the value must stand still before the suggestion route is asked. /// The shipped `setTimeout`, unchanged. const SUGGEST_WAIT: std::time::Duration = std::time::Duration::from_millis(200); /// How long it must stand still before the results are re-read. The shipped /// `delay:150ms`, unchanged, and deliberately not the same number as the one /// above: a suggestion list is cheap to be wrong about and a full result page /// is not. const RESULTS_WAIT: std::time::Duration = std::time::Duration::from_millis(150); /// How much value there must be before the suggestion route is asked at all. /// /// The shipped `q.length < 2` guard, moved from a renderer's file to the /// description. Load-bearing rather than cosmetic: /// `search_suggestions_handler` guards emptiness and nothing else, so without /// this a described box asks it about single letters, which is the query over /// every project and item in the catalogue that the floor exists to refuse. const FLOOR: usize = 2; /// One row the suggestion route found, as the screen needs it. #[derive(Debug, Clone)] pub struct Hit { /// What is read. pub label: String, /// What kind of thing it is — project, item, creator — which is what tells /// two rows reading alike apart. pub category: String, /// The page picking it goes to. pub url: String, } /// The box, its list, and the two questions it asks about one value. /// /// A fragment landing inside a document Askama already built, the same shape /// [`crate::quasi::discover_typeahead::tag_box`] takes. /// /// `mode` is `projects` or anything else, which is what the label and the ghost /// text say out loud; `value` is what is in the box, so a search survives the /// full-page reload every mode toggle performs. #[must_use] pub fn search_box(mode: &str, value: &str) -> String { use quasi_axum::Serves as _; let noun = if mode == "projects" { "projects" } else { "items" }; let mut field = Field::new(FieldKind::Text, FIELD, format!("Search {noun}")) .suggesting( Consult::new(Action::get(SUGGEST_ROUTE)) .after(SUGGEST_WAIT) .at_least(FLOOR), ) .consulting( Consult::new(Action::get(RESULTS_ROUTE).replacing(RESULTS)) .after(RESULTS_WAIT) .sending(FILTERS), ); field.placeholder = Some(format!("Search {noun}...")); if !value.is_empty() { field = field.value(value); } quasi_webview::Webview::new().fragment(&Node::field(field)) } /// The answer to the suggestion question: the inside of the list, and nothing /// else. #[must_use] pub fn suggestions(hits: &[Hit]) -> String { use quasi_axum::Serves as _; let candidates: Vec = hits.iter().map(candidate).collect(); quasi_webview::Webview::new().suggestions(FIELD, &candidates) } /// One row: what it reads as, what tells it from its neighbours, and the page /// picking it goes to. /// /// The value is the address, which is what identifies the row; nothing is ever /// written into the box, because picking navigates. That is /// [`Candidate::picks`] doing what it was designed for — this is the site it /// was designed from. fn candidate(hit: &Hit) -> Candidate { let mut candidate = Candidate::new(&hit.url, &hit.label).picking(Action::get(&hit.url).navigating()); if !hit.category.is_empty() { candidate = candidate.detailed(&hit.category); } candidate } #[cfg(test)] mod tests { use super::*; fn hit(label: &str, category: &str, url: &str) -> Hit { Hit { label: label.to_owned(), category: category.to_owned(), url: url.to_owned(), } } /// The three numbers that used to be literals in `page-discover.js`: where /// the list comes from, how long the box waits, and how much it waits for. #[test] fn the_box_says_where_its_list_comes_from_and_what_it_waits_for() { let html = search_box("items", ""); assert!(html.contains(r#"hx-get="/discover/suggestions""#), "{html}"); assert!(html.contains("delay:200ms"), "{html}"); assert!(html.contains("value.length>=2"), "{html}"); // The box owns the list, so both are addressed off the field's name and // nothing is authored. assert!(html.contains(r#"role="combobox""#), "{html}"); assert!(html.contains(r#"aria-controls="q-suggestions""#), "{html}"); assert!(html.contains(r#"id="q-suggestions""#), "{html}"); } /// The second question, which is the one the box already asked in markup: /// the results under the whole current filter set, at its own rate. #[test] fn the_same_value_re_reads_the_results_under_the_current_filters() { let html = search_box("items", ""); assert!(html.contains(r#"hx-get="/discover/results""#), "{html}"); assert!(html.contains("delay:150ms"), "{html}"); assert!( html.contains(r##"hx-target="#results-container""##), "{html}" ); for name in FILTERS { assert!(html.contains(&format!("[name='{name}']")), "{html}"); } } /// What is in the box survives the mode toggle, which reloads the page. #[test] fn the_box_holds_the_search_it_was_drawn_under() { let html = search_box("projects", "ambient pads"); assert!(html.contains(r#"value="ambient pads""#), "{html}"); assert!(html.contains("Search projects"), "{html}"); } /// `00ee7af5`, and the reason this site waited: a pick replaces the whole /// document, so the row is a link the browser follows and htmx is not /// involved at all. An `hx-get` here would land a whole page inside the /// suggestion row. #[test] fn picking_a_candidate_navigates_rather_than_swapping() { let html = suggestions(&[hit("Slow Reader", "Project", "/p/slow-reader")]); assert!(html.contains(r#"href="/p/slow-reader""#), "{html}"); assert!(!html.contains("hx-get"), "no htmx on a navigation: {html}"); assert!(!html.contains("hx-swap"), "{html}"); // Nothing is written into the box: the typed value is discarded when // the page is left. assert!(!html.contains("data-value"), "{html}"); } /// `1fcf2e9b`. What kind of thing a row is tells a project from an item /// with the same title, and it is its own element rather than part of the /// label. #[test] fn the_kind_of_page_is_the_second_line() { let html = suggestions(&[hit("Slow Reader", "Creator", "/u/slowreader")]); assert!(html.contains("form-suggestion-detail"), "{html}"); assert!(html.contains(">Creator<"), "{html}"); } /// A title somebody chose is app text on the newest path to the page. #[test] fn a_title_a_creator_chose_cannot_smuggle_markup() { let html = suggestions(&[hit("", "Item", "/i/1")]); assert!(!html.contains("