Skip to main content

laterite_admin/
checklist.rs

1//! The checklist: checkboxes in groups, one component for every screen.
2//!
3//! A screen describes the list; the component decides how to present it from
4//! its size. Past [`SELECT_ALL_PAST`] choices it offers select all and none,
5//! past [`SEARCH_PAST`] a search box, and past [`COLLAPSE_PAST`] choices in
6//! more than one group it closes the groups that are settled (every choice
7//! ticked, or none) and opens the ones partly ticked. Each decision has an
8//! override: [`Checklist::select_all`], [`Checklist::search`],
9//! [`Checklist::expand`].
10//!
11//! ```
12//! use laterite_admin::checklist::{Checklist, Choice};
13//! use laterite_core::Translator;
14//!
15//! let html = Checklist::new("Topics")
16//!     .choice(Choice::new("topic", "news", "News").checked(true))
17//!     .group("Sport", vec![
18//!         Choice::new("topic", "football", "Football").into(),
19//!         Choice::new("topic", "cricket", "Cricket").note("Tests and one-day").into(),
20//!     ])
21//!     .render(&Translator::new("en"));
22//! assert!(html.as_str().contains(r#"name="topic" value="football""#));
23//! ```
24//!
25//! The markup works before any script runs: the boxes submit and the groups
26//! open and close. The `checklist` island adds the group boxes, the counts that
27//! follow the ticks, the search and the `lat:checklist:changed` event.
28
29use askama::Template;
30use laterite_core::{t, Translator};
31use serde::{Deserialize, Serialize};
32
33use crate::html::Markup;
34
35/// Select all and select none are offered past this many choices.
36pub const SELECT_ALL_PAST: usize = 10;
37/// The search box is offered past this many choices.
38pub const SEARCH_PAST: usize = 20;
39/// Settled groups start closed past this many choices.
40pub const COLLAPSE_PAST: usize = 10;
41
42/// Whether a part of the checklist is offered: decided from the size of the
43/// list, or stated. In a descriptor file: `auto`, `true`, `false`.
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
45pub enum Offer {
46    /// Offered when the list is long enough to need it.
47    #[default]
48    Auto,
49    Always,
50    Never,
51}
52
53impl Offer {
54    fn decide(self, total: usize, past: usize) -> bool {
55        match self {
56            Offer::Auto => total > past,
57            Offer::Always => true,
58            Offer::Never => false,
59        }
60    }
61}
62
63impl Serialize for Offer {
64    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
65        match self {
66            Offer::Auto => s.serialize_str("auto"),
67            Offer::Always => s.serialize_bool(true),
68            Offer::Never => s.serialize_bool(false),
69        }
70    }
71}
72
73impl<'de> Deserialize<'de> for Offer {
74    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
75        struct Seen;
76        impl serde::de::Visitor<'_> for Seen {
77            type Value = Offer;
78            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79                f.write_str("`auto`, `true` or `false`")
80            }
81            fn visit_bool<E: serde::de::Error>(self, on: bool) -> Result<Offer, E> {
82                Ok(if on { Offer::Always } else { Offer::Never })
83            }
84            fn visit_str<E: serde::de::Error>(self, text: &str) -> Result<Offer, E> {
85                match text {
86                    "auto" => Ok(Offer::Auto),
87                    "true" => Ok(Offer::Always),
88                    "false" => Ok(Offer::Never),
89                    other => Err(E::invalid_value(serde::de::Unexpected::Str(other), &self)),
90                }
91            }
92        }
93        de.deserialize_any(Seen)
94    }
95}
96
97/// Which groups start open.
98#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum Expand {
101    /// A long list closes its settled groups and opens the partly ticked ones;
102    /// a short list, or one with a single group, opens everything.
103    #[default]
104    Auto,
105    /// Every group open.
106    All,
107    /// Every group closed.
108    None,
109}
110
111/// One checkbox.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[non_exhaustive]
114pub struct Choice {
115    /// The name the box submits under.
116    pub name: String,
117    /// The value it submits.
118    pub value: String,
119    pub label: String,
120    /// A second line under the label.
121    pub note: Option<String>,
122    /// An identifier shown beside the label, in the code face.
123    pub code: Option<String>,
124    pub checked: bool,
125    /// Shown, and not changeable: the box is disabled, so the browser leaves it
126    /// out of the submission whatever its state.
127    pub locked: bool,
128}
129
130impl Choice {
131    pub fn new(
132        name: impl Into<String>,
133        value: impl Into<String>,
134        label: impl Into<String>,
135    ) -> Self {
136        Self {
137            name: name.into(),
138            value: value.into(),
139            label: label.into(),
140            note: None,
141            code: None,
142            checked: false,
143            locked: false,
144        }
145    }
146
147    pub fn note(mut self, note: impl Into<String>) -> Self {
148        self.note = Some(note.into());
149        self
150    }
151
152    pub fn code(mut self, code: impl Into<String>) -> Self {
153        self.code = Some(code.into());
154        self
155    }
156
157    pub fn checked(mut self, checked: bool) -> Self {
158        self.checked = checked;
159        self
160    }
161
162    pub fn locked(mut self, locked: bool) -> Self {
163        self.locked = locked;
164        self
165    }
166}
167
168/// A heading over entries of its own. Its box is ticked when all of them are,
169/// clear when none is, and in between otherwise.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[non_exhaustive]
172pub struct Group {
173    pub label: String,
174    pub entries: Vec<Entry>,
175}
176
177impl Group {
178    pub fn new(label: impl Into<String>, entries: Vec<Entry>) -> Self {
179        Self {
180            label: label.into(),
181            entries,
182        }
183    }
184}
185
186/// A line of the checklist: a choice, or a group of lines.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[non_exhaustive]
189pub enum Entry {
190    Choice(Choice),
191    Group(Group),
192}
193
194impl From<Choice> for Entry {
195    fn from(choice: Choice) -> Self {
196        Entry::Choice(choice)
197    }
198}
199
200impl From<Group> for Entry {
201    fn from(group: Group) -> Self {
202        Entry::Group(group)
203    }
204}
205
206impl Entry {
207    /// `(ticked, total)` over the choices at and under this entry.
208    fn tally(&self) -> (usize, usize) {
209        match self {
210            Entry::Choice(choice) => (usize::from(choice.checked), 1),
211            Entry::Group(group) => tally(&group.entries),
212        }
213    }
214}
215
216fn tally(entries: &[Entry]) -> (usize, usize) {
217    entries
218        .iter()
219        .map(Entry::tally)
220        .fold((0, 0), |sum, one| (sum.0 + one.0, sum.1 + one.1))
221}
222
223/// A checklist, as a screen describes it.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct Checklist {
226    label: String,
227    id: Option<String>,
228    entries: Vec<Entry>,
229    select_all: Offer,
230    search: Offer,
231    expand: Expand,
232    read_only: bool,
233}
234
235impl Checklist {
236    /// An empty checklist. `label` names it to a screen reader.
237    pub fn new(label: impl Into<String>) -> Self {
238        Self {
239            label: label.into(),
240            id: None,
241            entries: Vec::new(),
242            select_all: Offer::Auto,
243            search: Offer::Auto,
244            expand: Expand::Auto,
245            read_only: false,
246        }
247    }
248
249    /// The element id of the list, for a link or a label to point at.
250    pub fn id(mut self, id: impl Into<String>) -> Self {
251        self.id = Some(id.into());
252        self
253    }
254
255    /// Adds a choice at the top level.
256    pub fn choice(mut self, choice: Choice) -> Self {
257        self.entries.push(Entry::Choice(choice));
258        self
259    }
260
261    /// Adds a group at the top level.
262    pub fn group(mut self, label: impl Into<String>, entries: Vec<Entry>) -> Self {
263        self.entries.push(Entry::Group(Group::new(label, entries)));
264        self
265    }
266
267    /// Adds an entry built elsewhere.
268    pub fn entry(mut self, entry: impl Into<Entry>) -> Self {
269        self.entries.push(entry.into());
270        self
271    }
272
273    /// Select all and select none. Default: past [`SELECT_ALL_PAST`] choices.
274    pub fn select_all(mut self, offer: Offer) -> Self {
275        self.select_all = offer;
276        self
277    }
278
279    /// The search box. Default: past [`SEARCH_PAST`] choices.
280    pub fn search(mut self, offer: Offer) -> Self {
281        self.search = offer;
282        self
283    }
284
285    /// Which groups start open. Default: [`Expand::Auto`].
286    pub fn expand(mut self, expand: Expand) -> Self {
287        self.expand = expand;
288        self
289    }
290
291    /// Shows the list without letting it change: every box is locked and
292    /// select all is withheld.
293    pub fn read_only(mut self, read_only: bool) -> Self {
294        self.read_only = read_only;
295        self
296    }
297
298    /// How many choices the list holds, at every depth.
299    pub fn total(&self) -> usize {
300        tally(&self.entries).1
301    }
302
303    /// How many of them are ticked.
304    pub fn checked(&self) -> usize {
305        tally(&self.entries).0
306    }
307
308    /// Resolves the list for rendering: every decision made, every string in
309    /// the reader's language. Serialisable, so a field type carries it in its
310    /// view-model and an override presents the same list.
311    pub fn view(&self, i18n: &Translator) -> ChecklistView {
312        let (checked, total) = tally(&self.entries);
313        let groups = self
314            .entries
315            .iter()
316            .filter(|e| matches!(e, Entry::Group(_)))
317            .count();
318        let closes = total > COLLAPSE_PAST && groups > 1;
319        // The pattern goes to the page with its placeholders intact, in the
320        // word order of the reader's language, for the island to fill in.
321        let pattern = i18n.t(&t!("{n} of {total}", n = "{n}", total = "{total}"));
322        let nodes = self
323            .entries
324            .iter()
325            .map(|e| node(e, self.expand, closes, self.read_only, &pattern))
326            .collect();
327        ChecklistView {
328            label: self.label.clone(),
329            id: self.id.clone(),
330            nodes,
331            select_all: !self.read_only && self.select_all.decide(total, SELECT_ALL_PAST),
332            search: self.search.decide(total, SEARCH_PAST),
333            read_only: self.read_only,
334            count: count(&pattern, checked, total),
335            pattern,
336            text_select_all: i18n.t(&t!("Select all")),
337            text_select_none: i18n.t(&t!("Select none")),
338            text_search: i18n.t(&t!("Search")),
339            text_no_match: i18n.t(&t!("Nothing matches.")),
340        }
341    }
342
343    /// The checklist as markup.
344    pub fn render(&self, i18n: &Translator) -> Markup {
345        self.view(i18n).render_markup()
346    }
347}
348
349fn count(pattern: &str, n: usize, total: usize) -> String {
350    pattern
351        .replace("{n}", &n.to_string())
352        .replace("{total}", &total.to_string())
353}
354
355fn node(entry: &Entry, expand: Expand, closes: bool, read_only: bool, pattern: &str) -> NodeView {
356    match entry {
357        Entry::Choice(choice) => NodeView {
358            group: false,
359            label: choice.label.clone(),
360            name: choice.name.clone(),
361            value: choice.value.clone(),
362            note: choice.note.clone(),
363            code: choice.code.clone(),
364            checked: choice.checked,
365            locked: read_only || choice.locked,
366            open: false,
367            count: String::new(),
368            nodes: Vec::new(),
369        },
370        Entry::Group(group) => {
371            let (checked, total) = tally(&group.entries);
372            let partly = checked > 0 && checked < total;
373            NodeView {
374                group: true,
375                label: group.label.clone(),
376                name: String::new(),
377                value: String::new(),
378                note: None,
379                code: None,
380                checked: total > 0 && checked == total,
381                locked: read_only,
382                open: match expand {
383                    Expand::All => true,
384                    Expand::None => false,
385                    Expand::Auto => !closes || partly,
386                },
387                count: count(pattern, checked, total),
388                nodes: group
389                    .entries
390                    .iter()
391                    .map(|e| node(e, expand, closes, read_only, pattern))
392                    .collect(),
393            }
394        }
395    }
396}
397
398/// A checklist resolved for rendering. Built by [`Checklist::view`].
399#[derive(Debug, Clone, Default, Serialize, Deserialize, Template)]
400#[template(path = "_checklist.html")]
401pub struct ChecklistView {
402    label: String,
403    id: Option<String>,
404    nodes: Vec<NodeView>,
405    select_all: bool,
406    search: bool,
407    read_only: bool,
408    /// The count over the whole list, as first rendered.
409    count: String,
410    /// The count's pattern, `{n}` and `{total}` intact.
411    pattern: String,
412    text_select_all: String,
413    text_select_none: String,
414    text_search: String,
415    text_no_match: String,
416}
417
418impl ChecklistView {
419    /// The view as markup.
420    pub fn render_markup(&self) -> Markup {
421        Markup::from_template(self).unwrap_or_default()
422    }
423
424    /// Whether the bar above the list has anything in it.
425    fn bar(&self) -> bool {
426        self.select_all || self.search
427    }
428}
429
430/// One line of a resolved checklist: a choice, or a group with lines of its own.
431#[derive(Debug, Clone, Default, Serialize, Deserialize, Template)]
432#[template(path = "_checklist_node.html")]
433struct NodeView {
434    group: bool,
435    label: String,
436    name: String,
437    value: String,
438    note: Option<String>,
439    code: Option<String>,
440    checked: bool,
441    locked: bool,
442    open: bool,
443    count: String,
444    nodes: Vec<NodeView>,
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    fn en() -> Translator {
452        Translator::new("en")
453    }
454
455    fn choices(prefix: &str, n: usize, ticked: usize) -> Vec<Entry> {
456        (0..n)
457            .map(|i| {
458                Choice::new("topic", format!("{prefix}{i}"), format!("{prefix} {i}"))
459                    .checked(i < ticked)
460                    .into()
461            })
462            .collect()
463    }
464
465    fn opens(view: &ChecklistView) -> Vec<bool> {
466        view.nodes
467            .iter()
468            .filter(|n| n.group)
469            .map(|n| n.open)
470            .collect()
471    }
472
473    #[test]
474    fn a_short_list_offers_no_bar_and_opens_every_group() {
475        let list = Checklist::new("Topics")
476            .group("One", choices("a", 3, 0))
477            .group("Two", choices("b", 3, 3));
478        let view = list.view(&en());
479        assert!(!view.select_all && !view.search);
480        assert_eq!(opens(&view), [true, true]);
481        assert_eq!(view.count, "3 of 6");
482    }
483
484    #[test]
485    fn a_long_list_closes_what_is_settled_and_opens_what_is_in_progress() {
486        let list = Checklist::new("Topics")
487            .group("None ticked", choices("a", 5, 0))
488            .group("Some ticked", choices("b", 5, 2))
489            .group("All ticked", choices("c", 5, 5));
490        let view = list.view(&en());
491        assert_eq!(opens(&view), [false, true, false]);
492        assert!(view.select_all, "past ten choices");
493        assert!(!view.search, "not past twenty");
494        let counts: Vec<&str> = view.nodes.iter().map(|n| n.count.as_str()).collect();
495        assert_eq!(counts, ["0 of 5", "2 of 5", "5 of 5"]);
496        assert!(view.nodes[2].checked && !view.nodes[1].checked);
497    }
498
499    #[test]
500    fn a_long_list_in_one_group_stays_open() {
501        let view = Checklist::new("Topics")
502            .group("Only", choices("a", 25, 0))
503            .view(&en());
504        assert_eq!(opens(&view), [true]);
505        assert!(view.search, "past twenty choices");
506    }
507
508    #[test]
509    fn every_decision_has_an_override() {
510        let list = Checklist::new("Topics")
511            .group("One", choices("a", 2, 0))
512            .group("Two", choices("b", 2, 1))
513            .select_all(Offer::Always)
514            .search(Offer::Always)
515            .expand(Expand::None);
516        let view = list.view(&en());
517        assert!(view.select_all && view.search);
518        assert_eq!(opens(&view), [false, false]);
519
520        let list = Checklist::new("Topics")
521            .group("One", choices("a", 15, 0))
522            .group("Two", choices("b", 15, 15))
523            .select_all(Offer::Never)
524            .search(Offer::Never)
525            .expand(Expand::All);
526        let view = list.view(&en());
527        assert!(!view.select_all && !view.search);
528        assert_eq!(opens(&view), [true, true]);
529    }
530
531    #[test]
532    fn a_group_inside_a_group_counts_toward_both() {
533        let inner = Group::new("Inner", choices("b", 2, 1));
534        let mut outer = choices("a", 2, 2);
535        outer.push(inner.into());
536        let list = Checklist::new("Topics").group("Outer", outer);
537        assert_eq!((list.checked(), list.total()), (3, 4));
538        let view = list.view(&en());
539        assert_eq!(view.nodes[0].count, "3 of 4");
540        assert_eq!(view.nodes[0].nodes[2].count, "1 of 2");
541
542        let html = list.render(&en());
543        let html = html.as_str();
544        assert_eq!(html.matches("<details").count(), 2);
545        assert_eq!(html.matches(r#"name="topic""#).count(), 4);
546    }
547
548    #[test]
549    fn a_read_only_list_locks_every_box_and_withholds_select_all() {
550        let list = Checklist::new("Topics")
551            .group("One", choices("a", 12, 12))
552            .read_only(true);
553        let view = list.view(&en());
554        assert!(!view.select_all);
555        let html = list.render(&en());
556        let html = html.as_str();
557        assert_eq!(
558            html.matches(" disabled").count(),
559            13,
560            "twelve boxes and the group's"
561        );
562        assert!(!html.contains("data-lat-checklist-all"));
563    }
564
565    #[test]
566    fn the_markup_escapes_what_a_screen_hands_it() {
567        let html = Checklist::new("Topics")
568            .choice(
569                Choice::new("topic", "a\"b", "<b>Bold</b>")
570                    .note("1 < 2")
571                    .code("x&y"),
572            )
573            .render(&en());
574        let html = html.as_str();
575        assert!(!html.contains("<b>Bold</b>"));
576        assert!(html.contains("&lt;b&gt;Bold&lt;/b&gt;") || html.contains("&#60;b&#62;"));
577        assert!(!html.contains(r#"value="a"b""#));
578    }
579
580    #[test]
581    fn offer_reads_a_word_or_a_boolean() {
582        let read = |text: &str| serde_json::from_str::<Offer>(text);
583        assert_eq!(read("\"auto\"").unwrap(), Offer::Auto);
584        assert_eq!(read("true").unwrap(), Offer::Always);
585        assert_eq!(read("false").unwrap(), Offer::Never);
586        assert!(read("\"sometimes\"").is_err());
587        assert_eq!(serde_json::to_string(&Offer::Always).unwrap(), "true");
588    }
589}