Skip to main content

max / quasi

9.8 KB · 295 lines History Blame Raw
1 //! A select whose options mark themselves, derived and filled.
2 //!
3 //! The one shape in this crate written for a feature rather than ported from a
4 //! screen, and it is here because the feature is what makes a select derivable
5 //! at all. Before per-option `chosen` a picker said which option was marked
6 //! once, at the field, as a value the renderer compared against each option --
7 //! and a residual holds ONE compiled body per loop, so "exactly one row
8 //! differs" was not something the body could carry. The derivation rendered the
9 //! value as a sentinel, it matched no option, and the residual baked a
10 //! synthesised unmatched option with the real one unmarked.
11 //!
12 //! Said per option it is a branch inside the row body, which is a shape a
13 //! residual has. That is the whole of the claim, and the two tests below are
14 //! it: the derivation finds a branch inside the loop, and filling reproduces
15 //! the renderer byte for byte with a theme marked and with none.
16 //!
17 //! MNW's `/dashboard/tabs/ssh-keys` is the screen that wanted it.
18
19 // The fixture data below is built by this module's tests and by nothing else:
20 // the bench itself measures the shapes, not the rows behind them.
21 #![allow(dead_code)]
22 use quasi_declare::declare;
23 use quasi_router::Choice;
24
25 /// One theme the picker offers.
26 ///
27 /// Carries `selected` per row, which is the fact the old spelling collapsed to
28 /// one string for the renderer to re-derive.
29 pub(crate) struct Theme {
30 pub id: String,
31 pub name: String,
32 pub selected: bool,
33 }
34
35 impl Theme {
36 fn new(id: &str, name: &str, selected: bool) -> Self {
37 Self {
38 id: id.into(),
39 name: name.into(),
40 selected,
41 }
42 }
43 }
44
45 /// Three themes with the second one marked.
46 pub(crate) fn themes(marked: Option<usize>) -> Vec<Theme> {
47 ["dark", "light", "paper"]
48 .iter()
49 .enumerate()
50 .map(|(index, id)| Theme::new(id, id, marked == Some(index)))
51 .collect()
52 }
53
54 declare! {
55 /// A range selector: one chip per range, the current one held down.
56 ///
57 /// MNW's analytics screens are the site, twice. `latched` settles the tag
58 /// its own statement produces, and it sits in a member's body rather than
59 /// an argument's -- the other place a settling setting turns up, and the
60 /// one that reaches through a loop.
61 #[staged]
62 pub(crate) shape ranges(chosen: &str) -> Vec<Node>;
63
64 for range in RANGES {
65 chip range.to_string() to get "/analytics?range={range}" navigating {
66 latched when is_shown(range, chosen);
67 }
68 }
69 }
70
71 declare! {
72 /// The region the chips are spliced into.
73 ///
74 /// A shape answering a run of members is derived through whatever holds
75 /// them, never on its own: its twin answers `Staged<Vec<Node>>`, and the
76 /// marks in that only mean anything once a container has said where the
77 /// members landed.
78 #[staged]
79 pub(crate) shape range_bar(chosen: &str) -> Node;
80
81 region "RANGES" as Pane {
82 include each ranges(chosen);
83 }
84 }
85
86 /// The ranges a selector offers.
87 pub(crate) const RANGES: &[&str] = &["7d", "30d", "90d", "all"];
88
89 /// Whether this chip is the one held down.
90 pub(crate) fn is_shown(range: &str, chosen: &str) -> bool {
91 range == chosen
92 }
93
94 /// What the settings form was asked to draw.
95 ///
96 /// The disclosure is the fact a guarded field turns on: MNW's mail settings put
97 /// six server questions on the form only while it is open.
98 pub(crate) struct Prefs {
99 pub themes: Vec<Theme>,
100 pub advanced: bool,
101 }
102
103 declare! {
104 /// The theme picker with an advanced question behind a disclosure.
105 ///
106 /// A form's fields are a bare list, so a guard on one is a marked run over
107 /// the wrapper they accrete into and the form absorbs it. The field either
108 /// side of the guarded one is what catches a mark that failed to move.
109 #[staged]
110 pub(crate) shape settings(prefs: &Prefs) -> Node;
111
112 form put "/api/users/me/console" awaiting {
113 submit "Save";
114
115 field Select "theme_id" "Console theme" {
116 for theme in prefs.themes.iter() {
117 option Choice::new(theme.id.clone(), theme.name.clone()) {
118 chosen when theme.selected;
119 }
120 }
121 }
122
123 field Text "endpoint" "Server" when prefs.advanced;
124
125 field Text "note" "Note";
126 }
127 }
128
129 declare! {
130 /// The theme picker.
131 #[staged]
132 pub(crate) shape picker(themes: &[Theme]) -> Node;
133
134 form put "/api/users/me/console-theme" awaiting {
135 submit "Save Theme";
136 field Select "theme_id" "Console theme" {
137 for theme in themes.iter() {
138 option Choice::new(theme.id.clone(), theme.name.clone()) {
139 chosen when theme.selected;
140 }
141 }
142 }
143 }
144 }
145
146 #[cfg(test)]
147 mod tests {
148 use quasi_http::Serves as _;
149 use quasi_router::stage::{Op, Plan, Residual};
150 use quasi_webview::Webview;
151
152 use super::*;
153
154 fn residual() -> Residual {
155 quasi_webview::stage::derive(&Webview::new(), picker_staged)
156 }
157
158 /// The branch is inside the loop, which is the whole point: the rows are
159 /// one compiled body and what differs between them is a branch in it.
160 fn prefs(advanced: bool) -> Prefs {
161 Prefs {
162 themes: themes(Some(1)),
163 advanced,
164 }
165 }
166
167 /// A guarded field is a branch, and the fields either side stay outside it.
168 /// A settling setting inside a loop, which is one arm per pass.
169 ///
170 /// The Select's problem stated on the other control: exactly one chip
171 /// differs and a residual holds one compiled loop body, so the difference
172 /// has to be an arm inside that body rather than a branch beside it.
173 #[test]
174 fn a_latched_chip_is_an_arm_inside_the_loop() {
175 let webview = Webview::new();
176 let residual = quasi_webview::stage::derive(&Webview::new(), range_bar_staged);
177 for chosen in ["7d", "all", "none of them"] {
178 assert_eq!(
179 webview.fragment(&range_bar(chosen)),
180 range_bar_serve(&residual, chosen),
181 "chosen {chosen}"
182 );
183 }
184 }
185
186 #[test]
187 fn a_guarded_field_is_a_branch_in_the_form() {
188 let webview = Webview::new();
189 let residual = quasi_webview::stage::derive(&Webview::new(), settings_staged);
190 for advanced in [false, true] {
191 let prefs = prefs(advanced);
192 assert_eq!(
193 webview.fragment(&settings(&prefs)),
194 settings_serve(&residual, &prefs),
195 "advanced {advanced}"
196 );
197 }
198 }
199
200 #[test]
201 fn the_marked_option_is_a_branch_inside_the_row() {
202 let residual = residual();
203
204 fn branch_under_loop(ops: &[Op], inside: bool) -> bool {
205 ops.iter().any(|op| match op {
206 Op::Lit(_) | Op::Hole { .. } => false,
207 Op::Branch(body) => inside || branch_under_loop(body, inside),
208 Op::Arms(arms) => inside || arms.iter().any(|arm| branch_under_loop(arm, inside)),
209 Op::Loop(body) => branch_under_loop(body, true),
210 })
211 }
212
213 assert!(
214 branch_under_loop(residual.ops(), false),
215 "{:#?}",
216 residual.ops()
217 );
218 }
219
220 /// The derivation reads a loop and a guard rather than baking the render it
221 /// happened to see.
222 #[test]
223 fn replaying_the_residual_reproduces_the_staged_render() {
224 let webview = Webview::new();
225 let residual = residual();
226
227 fn replay(ops: &[Op], rows: usize, out: &mut String) {
228 for op in ops {
229 match op {
230 Op::Lit(text) => out.push_str(text),
231 Op::Hole { scope, id } => {
232 out.push_str(&quasi_router::stage::sentinel_at(*scope, *id));
233 }
234 Op::Branch(body) => replay(body, rows, out),
235 Op::Arms(arms) => replay(&arms[0], rows, out),
236 Op::Loop(body) => {
237 for _ in 0..rows {
238 replay(body, rows, out);
239 }
240 }
241 }
242 }
243 }
244
245 for rows in [1, 2, 5] {
246 let mut replayed = String::new();
247 replay(residual.ops(), rows, &mut replayed);
248 assert_eq!(
249 webview.fragment(&picker_staged(&Plan::full(rows))),
250 replayed,
251 "the residual and the renderer disagree at {rows} rows"
252 );
253 }
254 }
255
256 /// Filling it is what the renderer would have produced, marked and unmarked.
257 ///
258 /// The second case is the one the old spelling could not serve at all: a
259 /// picker with nothing chosen rendered the guard's absent value as a
260 /// sentinel and baked an unmatched option around it.
261 #[test]
262 fn a_filled_picker_is_what_the_renderer_would_have_produced() {
263 let webview = Webview::new();
264 let residual = residual();
265
266 for marked in [Some(0), Some(1), Some(2), None] {
267 let themes = themes(marked);
268 let filled = picker_serve(&residual, &themes);
269 assert_eq!(
270 webview.fragment(&picker(&themes)),
271 filled,
272 "marked {marked:?}"
273 );
274 assert_eq!(
275 filled.matches(" selected").count(),
276 usize::from(marked.is_some()),
277 "{filled}"
278 );
279 }
280 }
281
282 /// An empty list still fills, and marks nothing.
283 #[test]
284 fn a_picker_with_no_themes_goes_through_the_same_residual() {
285 let webview = Webview::new();
286 let residual = residual();
287 let themes = Vec::new();
288
289 assert_eq!(
290 webview.fragment(&picker(&themes)),
291 picker_serve(&residual, &themes)
292 );
293 }
294 }
295