Skip to main content

max / makenotwork

10.7 KB · 261 lines History Blame Raw
1 //! The Askama entry point for the discover screen's described search box.
2 //!
3 //! N8's second measured site, and the one the tag typeahead (`1ea614c8`) could
4 //! not go with. Its candidates navigate: each one is a project, an item or a
5 //! creator page, so picking a row leaves the screen rather than changing part
6 //! of it. Until [`Action::navigates`] existed a pick could only be spelled
7 //! `Action::get(url)`, which emits an `href` *and* an `hx-get` with no target,
8 //! and htmx puts the whole document inside the row that was clicked. That is
9 //! quasicoherent `00ee7af5`, and it is the whole of why this waited.
10 //!
11 //! # What is described here, and what was hand-written before
12 //!
13 //! Described: that the box owns a list, where the list comes from, that the
14 //! value must stand still 200ms and carry two characters before the route is
15 //! asked, that picking a row goes to the page it names, and separately that the
16 //! same value re-reads the results under the current filters after 150ms. Every
17 //! one of those was a literal in `static/page-discover.js` — the debounce, the
18 //! `q.length < 2` guard, the fetch, the row markup, arrow-key movement, and the
19 //! `<a href>` each row was built as by hand.
20 //!
21 //! Host: nothing. The last block of that file goes with this.
22 //!
23 //! # Two questions about one value
24 //!
25 //! This is the box [`Field::consults`] was made a `Vec` for. The suggestion
26 //! list and the results are two questions about the same string, asked at two
27 //! rates and answered in two places, and only one of them used to be sayable.
28 //! The suggestion one is [`Field::suggests`], because its answer is this
29 //! field's own list of candidates; the results one is an ordinary consult
30 //! landing in the region every other filter control lands in.
31 //!
32 //! The filters ride with the results question and not with the suggestion one.
33 //! A pick here goes to a page, so there is nothing for the current facets to be
34 //! carried forward into — unlike the tag box, where a pick adds a facet to the
35 //! set it was offered under.
36 //!
37 //! # What the described box does not carry
38 //!
39 //! Two attributes the hand-written markup had and the vocabulary has no word
40 //! for, both deliberate rather than overlooked:
41 //!
42 //! - `hx-indicator="#search-spinner"`. Nothing says "while this call is out,
43 //! show that". The spinner still fires for every other control on the screen.
44 //! - `hx-trigger="…, search"`, the event a `type="search"` box fires when its
45 //! clear affordance is pressed. A described field is a
46 //! [`FieldKind::Text`], and clearing it by keyboard asks as it always did.
47 //!
48 //! [`Action::navigates`]: quasi_router::Action::navigates
49 //! [`Field::consults`]: quasi_router::Field::consults
50 //! [`Field::suggests`]: quasi_router::Field::suggests
51
52 use makeover_layout::FieldKind;
53 use quasi_router::{Action, Candidate, Consult, Field, Node};
54
55 use super::discover_typeahead::FILTERS;
56
57 /// The field's name, and therefore the id of the box and the stem of its
58 /// list's.
59 ///
60 /// `q` is what the search value has always been submitted under: it is what
61 /// `/discover` and `/discover/results` read, what every other filter control
62 /// sends along, and one of the eleven names a tag pick carries forward. So the
63 /// described box takes the name the screen already had rather than a new one.
64 pub const FIELD: &str = "q";
65
66 /// The region the results question's answer replaces.
67 ///
68 /// `/discover/results` is an Askama route the description layer does not serve,
69 /// so it cannot name what it changed and the control has to. The same constant
70 /// the tag box needs, for the same reason.
71 const RESULTS: &str = "results-container";
72
73 /// What the second question asks: the results under the current filters.
74 const RESULTS_ROUTE: &str = "/discover/results";
75
76 /// The route the box asks about what is being typed.
77 const SUGGEST_ROUTE: &str = "/discover/suggestions";
78
79 /// How long the value must stand still before the suggestion route is asked.
80 /// The shipped `setTimeout`, unchanged.
81 const SUGGEST_WAIT: std::time::Duration = std::time::Duration::from_millis(200);
82
83 /// How long it must stand still before the results are re-read. The shipped
84 /// `delay:150ms`, unchanged, and deliberately not the same number as the one
85 /// above: a suggestion list is cheap to be wrong about and a full result page
86 /// is not.
87 const RESULTS_WAIT: std::time::Duration = std::time::Duration::from_millis(150);
88
89 /// How much value there must be before the suggestion route is asked at all.
90 ///
91 /// The shipped `q.length < 2` guard, moved from a renderer's file to the
92 /// description. Load-bearing rather than cosmetic:
93 /// `search_suggestions_handler` guards emptiness and nothing else, so without
94 /// this a described box asks it about single letters, which is the query over
95 /// every project and item in the catalogue that the floor exists to refuse.
96 const FLOOR: usize = 2;
97
98 /// One row the suggestion route found, as the screen needs it.
99 #[derive(Debug, Clone)]
100 pub struct Hit {
101 /// What is read.
102 pub label: String,
103 /// What kind of thing it is — project, item, creator — which is what tells
104 /// two rows reading alike apart.
105 pub category: String,
106 /// The page picking it goes to.
107 pub url: String,
108 }
109
110 /// The box, its list, and the two questions it asks about one value.
111 ///
112 /// A fragment landing inside a document Askama already built, the same shape
113 /// [`crate::quasi::discover_typeahead::tag_box`] takes.
114 ///
115 /// `mode` is `projects` or anything else, which is what the label and the ghost
116 /// text say out loud; `value` is what is in the box, so a search survives the
117 /// full-page reload every mode toggle performs.
118 #[must_use]
119 pub fn search_box(mode: &str, value: &str) -> String {
120 use quasi_axum::Serves as _;
121
122 let noun = if mode == "projects" {
123 "projects"
124 } else {
125 "items"
126 };
127 let mut field = Field::new(FieldKind::Text, FIELD, format!("Search {noun}"))
128 .suggesting(
129 Consult::new(Action::get(SUGGEST_ROUTE))
130 .after(SUGGEST_WAIT)
131 .at_least(FLOOR),
132 )
133 .consulting(
134 Consult::new(Action::get(RESULTS_ROUTE).replacing(RESULTS))
135 .after(RESULTS_WAIT)
136 .sending(FILTERS),
137 );
138 field.placeholder = Some(format!("Search {noun}..."));
139 if !value.is_empty() {
140 field = field.value(value);
141 }
142
143 quasi_webview::Webview::new().fragment(&Node::field(field))
144 }
145
146 /// The answer to the suggestion question: the inside of the list, and nothing
147 /// else.
148 #[must_use]
149 pub fn suggestions(hits: &[Hit]) -> String {
150 use quasi_axum::Serves as _;
151
152 let candidates: Vec<Candidate> = hits.iter().map(candidate).collect();
153 quasi_webview::Webview::new().suggestions(FIELD, &candidates)
154 }
155
156 /// One row: what it reads as, what tells it from its neighbours, and the page
157 /// picking it goes to.
158 ///
159 /// The value is the address, which is what identifies the row; nothing is ever
160 /// written into the box, because picking navigates. That is
161 /// [`Candidate::picks`] doing what it was designed for — this is the site it
162 /// was designed from.
163 fn candidate(hit: &Hit) -> Candidate {
164 let mut candidate =
165 Candidate::new(&hit.url, &hit.label).picking(Action::get(&hit.url).navigating());
166 if !hit.category.is_empty() {
167 candidate = candidate.detailed(&hit.category);
168 }
169 candidate
170 }
171
172 #[cfg(test)]
173 mod tests {
174 use super::*;
175
176 fn hit(label: &str, category: &str, url: &str) -> Hit {
177 Hit {
178 label: label.to_owned(),
179 category: category.to_owned(),
180 url: url.to_owned(),
181 }
182 }
183
184 /// The three numbers that used to be literals in `page-discover.js`: where
185 /// the list comes from, how long the box waits, and how much it waits for.
186 #[test]
187 fn the_box_says_where_its_list_comes_from_and_what_it_waits_for() {
188 let html = search_box("items", "");
189
190 assert!(html.contains(r#"hx-get="/discover/suggestions""#), "{html}");
191 assert!(html.contains("delay:200ms"), "{html}");
192 assert!(html.contains("value.length&gt;=2"), "{html}");
193 // The box owns the list, so both are addressed off the field's name and
194 // nothing is authored.
195 assert!(html.contains(r#"role="combobox""#), "{html}");
196 assert!(html.contains(r#"aria-controls="q-suggestions""#), "{html}");
197 assert!(html.contains(r#"id="q-suggestions""#), "{html}");
198 }
199
200 /// The second question, which is the one the box already asked in markup:
201 /// the results under the whole current filter set, at its own rate.
202 #[test]
203 fn the_same_value_re_reads_the_results_under_the_current_filters() {
204 let html = search_box("items", "");
205
206 assert!(html.contains(r#"hx-get="/discover/results""#), "{html}");
207 assert!(html.contains("delay:150ms"), "{html}");
208 assert!(
209 html.contains(r##"hx-target="#results-container""##),
210 "{html}"
211 );
212 for name in FILTERS {
213 assert!(html.contains(&format!("[name=&#39;{name}&#39;]")), "{html}");
214 }
215 }
216
217 /// What is in the box survives the mode toggle, which reloads the page.
218 #[test]
219 fn the_box_holds_the_search_it_was_drawn_under() {
220 let html = search_box("projects", "ambient pads");
221
222 assert!(html.contains(r#"value="ambient pads""#), "{html}");
223 assert!(html.contains("Search projects"), "{html}");
224 }
225
226 /// `00ee7af5`, and the reason this site waited: a pick replaces the whole
227 /// document, so the row is a link the browser follows and htmx is not
228 /// involved at all. An `hx-get` here would land a whole page inside the
229 /// suggestion row.
230 #[test]
231 fn picking_a_candidate_navigates_rather_than_swapping() {
232 let html = suggestions(&[hit("Slow Reader", "Project", "/p/slow-reader")]);
233
234 assert!(html.contains(r#"href="/p/slow-reader""#), "{html}");
235 assert!(!html.contains("hx-get"), "no htmx on a navigation: {html}");
236 assert!(!html.contains("hx-swap"), "{html}");
237 // Nothing is written into the box: the typed value is discarded when
238 // the page is left.
239 assert!(!html.contains("data-value"), "{html}");
240 }
241
242 /// `1fcf2e9b`. What kind of thing a row is tells a project from an item
243 /// with the same title, and it is its own element rather than part of the
244 /// label.
245 #[test]
246 fn the_kind_of_page_is_the_second_line() {
247 let html = suggestions(&[hit("Slow Reader", "Creator", "/u/slowreader")]);
248
249 assert!(html.contains("form-suggestion-detail"), "{html}");
250 assert!(html.contains(">Creator<"), "{html}");
251 }
252
253 /// A title somebody chose is app text on the newest path to the page.
254 #[test]
255 fn a_title_a_creator_chose_cannot_smuggle_markup() {
256 let html = suggestions(&[hit("<script>x()</script>", "Item", "/i/1")]);
257
258 assert!(!html.contains("<script>x()"), "{html}");
259 }
260 }
261