1use askama::Template;
30use laterite_core::{t, Translator};
31use serde::{Deserialize, Serialize};
32
33use crate::html::Markup;
34
35pub const SELECT_ALL_PAST: usize = 10;
37pub const SEARCH_PAST: usize = 20;
39pub const COLLAPSE_PAST: usize = 10;
41
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
45pub enum Offer {
46 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum Expand {
101 #[default]
104 Auto,
105 All,
107 None,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[non_exhaustive]
114pub struct Choice {
115 pub name: String,
117 pub value: String,
119 pub label: String,
120 pub note: Option<String>,
122 pub code: Option<String>,
124 pub checked: bool,
125 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#[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#[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 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#[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 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 pub fn id(mut self, id: impl Into<String>) -> Self {
251 self.id = Some(id.into());
252 self
253 }
254
255 pub fn choice(mut self, choice: Choice) -> Self {
257 self.entries.push(Entry::Choice(choice));
258 self
259 }
260
261 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 pub fn entry(mut self, entry: impl Into<Entry>) -> Self {
269 self.entries.push(entry.into());
270 self
271 }
272
273 pub fn select_all(mut self, offer: Offer) -> Self {
275 self.select_all = offer;
276 self
277 }
278
279 pub fn search(mut self, offer: Offer) -> Self {
281 self.search = offer;
282 self
283 }
284
285 pub fn expand(mut self, expand: Expand) -> Self {
287 self.expand = expand;
288 self
289 }
290
291 pub fn read_only(mut self, read_only: bool) -> Self {
294 self.read_only = read_only;
295 self
296 }
297
298 pub fn total(&self) -> usize {
300 tally(&self.entries).1
301 }
302
303 pub fn checked(&self) -> usize {
305 tally(&self.entries).0
306 }
307
308 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 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 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#[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 count: String,
410 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 pub fn render_markup(&self) -> Markup {
421 Markup::from_template(self).unwrap_or_default()
422 }
423
424 fn bar(&self) -> bool {
426 self.select_all || self.search
427 }
428}
429
430#[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("<b>Bold</b>") || html.contains("<b>"));
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}