pub struct ViewArgs(/* private fields */);Expand description
What a view was asked for.
It holds the address’s parameters, with a declared parameter the address
left out filled in from its default. Everything the address carried is
kept, including keys no parameter names, so a view may read more than it
declares; ViewArgs::iter walks all of it.
A parameter the address gave a value its options no longer offer falls back to the default. That is what happens when one parameter’s options depend on another’s value and the other has just changed: a subgenre that belonged to the genre before this one is not an answer to the question being asked now. An empty value is a value: a parameter cleared on purpose stays cleared rather than filling itself in again.
An action is asked with the same arguments the page it was on was asked
with, and with the arguments its own form carried, so ViewLogic::act
reads which thing it is writing about the same way ViewLogic::render
reads which thing it is drawing.
Implementations§
Source§impl ViewArgs
impl ViewArgs
pub fn get(&self, key: &str) -> Option<&str>
Sourcepub fn get_or<'a>(&'a self, key: &str, fallback: &'a str) -> &'a str
pub fn get_or<'a>(&'a self, key: &str, fallback: &'a str) -> &'a str
The value of key, or fallback where the address and the parameter’s
own default both said nothing.
Examples found in repository?
550 fn params(&self, ctx: &Context, asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
551 let branches = Self::branches(ctx)?;
552 let options: Vec<SelectOption> = branches
553 .iter()
554 .map(|b| SelectOption::labelled(&b.code, &b.name))
555 .collect();
556 let first = branches.first().map(|b| b.code.clone()).unwrap_or_default();
557
558 // A genre and one of its subgenres. The second list follows the first
559 // one's answer, which is why `params` is told what was asked before
560 // anything is settled. A subgenre belonging to the genre chosen last
561 // time is no answer to the question being asked now, and falls back to
562 // every subgenre of the genre chosen this time.
563 let genres: Vec<Genre> = ctx.optional_rows(GENRES_FILE)?;
564 let mut named: Vec<&str> = genres.iter().map(|g| g.genre.as_str()).collect();
565 named.sort_unstable();
566 named.dedup();
567
568 let chosen = asked.get_or("genre", "");
569 let mut subgenres: Vec<&str> = genres
570 .iter()
571 .filter(|g| g.genre == chosen)
572 .map(|g| g.subgenre.as_str())
573 .collect();
574 subgenres.sort_unstable();
575
576 Ok(vec![
577 Param::select("branch", "Branch", options).default(first),
578 Param::select("genre", "Genre", Self::any("Every genre", named)).default(""),
579 Param::select(
580 "subgenre",
581 "Subgenre",
582 Self::any("Every subgenre", subgenres),
583 )
584 .default(""),
585 Param::string("author", "Author"),
586 ])
587 }
588
589 fn render(&self, args: &ViewArgs, ctx: &Context) -> Result<ViewData, ApiError> {
590 let books: Vec<Book> = ctx.optional_rows(BOOKS_FILE)?;
591 let branches = Self::branches(ctx)?;
592 let code = args.get_or("branch", "");
593 let branch = branches.iter().find(|b| b.code == code);
594 let named = branch.map(|b| b.name.as_str()).unwrap_or(code);
595
596 let genre = args.get_or("genre", "");
597 let subgenre = args.get_or("subgenre", "");
598 let author = args.get_or("author", "").trim().to_lowercase();
599
600 // The books this branch holds that answer the rest of the question,
601 // whether they are out or not. A parameter left empty asks nothing.
602 let held: Vec<&Book> = books
603 .iter()
604 .filter(|b| b.shelved.contains_key(code))
605 .filter(|b| genre.is_empty() || b.genre == genre)
606 .filter(|b| subgenre.is_empty() || b.subgenre == subgenre)
607 .filter(|b| {
608 author.is_empty()
609 || format!("{} {}", b.author_first, b.author_last)
610 .to_lowercase()
611 .contains(&author)
612 })
613 .collect();
614
615 let now = today();
616 let mut out = Vec::new();
617 let mut overdue = Vec::new();
618 for book in held.iter().filter(|b| b.lent.unwrap_or(false)) {
619 let days = days_from_civil(&book.due).map(|due| now - due).unwrap_or(0);
620 if days > 0 {
621 overdue.push(OnLoan::row(book, days));
622 } else {
623 out.push(OnLoan::row(book, days));
624 }
625 }
626
627 let lent = out.len() + overdue.len();
628 Ok(ViewData::new()
629 .note(format!(
630 "{lent} of {} book(s) at {named} are out on loan. A title links to its catalogue entry.",
631 held.len()
632 ))
633 .section(
634 Section::new(OnLoan::columns())
635 .heading("Out")
636 .note("Days left before they are due.")
637 .rows(out)?,
638 )
639 .section(
640 Section::new(OnLoan::columns())
641 .heading("Overdue")
642 .note("Days past due. Chase these.")
643 .rows(overdue)?,
644 ))
645 }
646}
647
648// ── The Branches view, which is a card per branch ────────────────────────────
649
650/// Every branch as a card, grouped by whether it is open.
651///
652/// A card is what a view answers with when the question is "how do these things
653/// stand" rather than "what are the values of these fields": there are no
654/// columns here, and each card says how one branch stands, what it is called,
655/// and two counts, with the whole card a link to the branch's own page.
656struct BranchCards;
657
658/// How many of the books a branch holds, and how many of those are out.
659fn shelf_counts(books: &[Book], code: &str) -> (usize, usize) {
660 let here: Vec<&Book> = books
661 .iter()
662 .filter(|b| b.shelved.contains_key(code))
663 .collect();
664 let out = here.iter().filter(|b| b.lent.unwrap_or(false)).count();
665 (here.len(), out)
666}
667
668fn librarian(branch: &Branch) -> String {
669 format!("{} {}", branch.librarian_first, branch.librarian_last)
670 .trim()
671 .to_string()
672}
673
674impl ViewLogic for BranchCards {
675 fn name(&self) -> &'static str {
676 "all-branches"
677 }
678
679 fn title(&self) -> &'static str {
680 "All branches"
681 }
682
683 fn render(&self, _args: &ViewArgs, ctx: &Context) -> Result<ViewData, ApiError> {
684 let branches: Vec<Branch> = ctx.optional_rows(BRANCHES_FILE)?;
685 let books: Vec<Book> = ctx.optional_rows(BOOKS_FILE)?;
686
687 let card = |branch: &Branch| {
688 let (here, out) = shelf_counts(&books, &branch.code);
689 let open = branch.open.unwrap_or(false);
690 let mut card = Card::new(branch.name.as_str())
691 .status(if open {
692 Status::new("Open", Tone::Good)
693 } else {
694 // A shut branch is a fact about it rather than a fault, so
695 // it is neutral—which is also what reads quieter.
696 Status::new("Shut", Tone::Neutral)
697 })
698 .identifier(branch.code.as_str())
699 .subtitle(format!(
700 "{}, {} staff",
701 librarian(branch),
702 branch.staff.unwrap_or(0)
703 ))
704 .row("Books here", here)
705 .row("Out on loan", out)
706 .link(ViewLink::new("branch").arg("branch", &branch.code));
707 if branch.hours.is_empty() {
708 card = card.sentence(
709 "No opening hours are recorded, so nothing here says when it can be visited.",
710 );
711 }
712 card
713 };
714
715 let group = |heading: &str, open: bool| {
716 CardGroup::new(heading).cards(
717 branches
718 .iter()
719 .filter(|b| b.open.unwrap_or(false) == open)
720 .map(&card),
721 )
722 };
723
724 Ok(ViewData::new()
725 .note("A card is a branch. Open one for what it holds and what is out.")
726 .group(group("Open", true))
727 .group(group("Shut", false)))
728 }
729}
730
731// ── The Branch view, which is one branch in detail ───────────────────────────
732
733/// One branch: what it has out, what is on its shelves, and when it is open.
734///
735/// It is reached from a card rather than from the switcher, so its one
736/// parameter is hidden: the page draws no control for it, and the card's link
737/// is what answers it. The parameter is a select all the same, so a link to a
738/// branch that has since gone falls back to the first one rather than to a page
739/// about nothing.
740struct BranchDetail;
741
742impl BranchDetail {
743 /// The books a branch holds, best rated first, which is the order the
744 /// numbered section is a ranking in.
745 fn by_rating<'a>(books: &'a [Book], code: &str, lent: bool) -> Vec<&'a Book> {
746 let mut held: Vec<&Book> = books
747 .iter()
748 .filter(|b| b.shelved.contains_key(code) && b.lent.unwrap_or(false) == lent)
749 .collect();
750 held.sort_by(|a, b| {
751 b.rating
752 .unwrap_or(0.0)
753 .total_cmp(&a.rating.unwrap_or(0.0))
754 .then_with(|| a.title.cmp(&b.title))
755 });
756 held
757 }
758
759 /// What a form asks before a book goes out. The four kinds of field are all
760 /// here, since this is the page the bundle's form controls are developed
761 /// against.
762 fn loan_form(title: &str) -> Form {
763 Form::new("lend-a-book")
764 .arg("title", title)
765 .field(Field::text("borrower", "Borrower"))
766 .field(Field::number("days", "Days out").default(21))
767 .field(Field::date("from", "Date lent").default(civil_from_days(today())))
768 .field(
769 Field::one_of(
770 "condition",
771 "Condition it left in",
772 ["As new", "Good", "Worn"],
773 )
774 .default("Good"),
775 )
776 }
777}
778
779impl ViewLogic for BranchDetail {
780 fn name(&self) -> &'static str {
781 "branch"
782 }
783
784 fn title(&self) -> &'static str {
785 "Branch"
786 }
787
788 /// This page is about one branch, and which one arrives through a card's
789 /// link. A switcher entry for it would open whichever branch the parameter
790 /// happens to default to, which is nobody's question.
791 fn in_switcher(&self) -> bool {
792 false
793 }
794
795 fn params(&self, ctx: &Context, _asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
796 let branches: Vec<Branch> = ctx.optional_rows(BRANCHES_FILE)?;
797 let codes: Vec<SelectOption> = branches
798 .iter()
799 .map(|b| SelectOption::labelled(&b.code, &b.name))
800 .collect();
801 let first = branches.first().map(|b| b.code.clone()).unwrap_or_default();
802 Ok(vec![
803 Param::select("branch", "Branch", codes)
804 .default(first)
805 .hidden(),
806 ])
807 }
808
809 fn render(&self, args: &ViewArgs, ctx: &Context) -> Result<ViewData, ApiError> {
810 let branches: Vec<Branch> = ctx.optional_rows(BRANCHES_FILE)?;
811 let books: Vec<Book> = ctx.optional_rows(BOOKS_FILE)?;
812 let code = args.get_or("branch", "");
813 let branch = branches
814 .iter()
815 .find(|b| b.code == code)
816 .ok_or_else(|| ApiError::new(404, format!("no branch has the code \"{code}\"")))?;
817
818 let (here, out) = shelf_counts(&books, code);
819 let now = today();
820
821 // What is out, with how it stands against its due date. A row's own
822 // link is the catalogue entry, and one book's link is a `javascript:`
823 // URL, which the page shows as text rather than following.
824 let mut lent = DetailSection::main("Out on loan");
825 for book in BranchDetail::by_rating(&books, code, true) {
826 let late = days_from_civil(&book.due).map(|due| now - due);
827 let mut row = DetailRow::new(book.title.as_str()).link(book.link.as_str());
828 if !book.due.is_empty() {
829 row = row.fact(format!("due {}", book.due));
830 }
831 row = match late {
832 Some(days) if days > 0 => row.fact(format!("{days} day(s) late")),
833 Some(days) => row.fact(format!("{} day(s) to go", -days)),
834 None => row.note("No due date is recorded."),
835 };
836 lent = lent.row(row.button(Button::disabled("Chase the borrower", "Not built yet")));
837 }
838 if out == 0 {
839 lent = lent.note("Everything this branch holds is on the shelf.");
840 }
841
842 // What can go out, ranked, with the one action this example offers.
843 let mut shelf = DetailSection::main("On the shelf")
844 .numbered()
845 .note("Best rated first.");
846 for book in BranchDetail::by_rating(&books, code, false) {
847 let mut row = DetailRow::new(book.title.as_str());
848 if let Some(rating) = book.rating {
849 row = row.fact(format!("rated {rating}"));
850 }
851 if let Some(year) = book.year {
852 row = row.fact(year);
853 }
854 if !book.notes.trim().is_empty() {
855 row = row.note(book.notes.as_str());
856 }
857 shelf = shelf.row(
858 row.button(Button::link("Catalogue", book.link.as_str()))
859 .button(Button::form(
860 "Lend it out",
861 BranchDetail::loan_form(&book.title),
862 )),
863 );
864 }
865 if here == out {
866 shelf = shelf.note("Nothing this branch holds is on the shelf today.");
867 }
868
869 let mut hours = DetailSection::side("When it is open").collapsed_on_phone();
870 for (day, open) in &branch.hours {
871 hours = hours.row(DetailRow::new(day.as_str()).fact(open.as_str()));
872 }
873 if branch.hours.is_empty() {
874 hours = hours.note("No hours are recorded for this branch.");
875 }
876
877 // A count per genre, which is a section of facts rather than of things
878 // to do, so it sits beside the page rather than in it.
879 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
880 for book in books.iter().filter(|b| b.shelved.contains_key(code)) {
881 *counts.entry(book.genre.as_str()).or_default() += 1;
882 }
883 let mut genres = DetailSection::side("Genres here");
884 for (genre, count) in counts {
885 genres = genres.row(DetailRow::new(genre).fact(count));
886 }
887
888 Ok(ViewData::new().detail(
889 Detail::new(branch.name.as_str())
890 .status(if branch.open.unwrap_or(false) {
891 Status::new("Open", Tone::Good)
892 } else {
893 Status::new("Shut", Tone::Neutral)
894 })
895 .subtitle(format!(
896 "{}, {} staff, {here} book(s) here, {out} out",
897 librarian(branch),
898 branch.staff.unwrap_or(0)
899 ))
900 .back(ViewLink::new("all-branches"))
901 .section(lent)
902 .section(shelf)
903 .section(hours)
904 .section(genres),
905 ))
906 }
907
908 /// Which action was asked for. The router only lets through a name a
909 /// button on this page offers, so the arm at the end is unreachable; it is
910 /// written all the same, because a page with two actions on it decides
911 /// between them here and that is what a consumer copies.
912 fn act(
913 &self,
914 name: &str,
915 fields: &Fields,
916 args: &ViewArgs,
917 ctx: &Context,
918 ) -> Result<String, ApiError> {
919 match name {
920 "lend-a-book" => BranchDetail::lend(fields, args, ctx),
921 _ => Err(ApiError::new(
922 404,
923 format!("this page has no action called \"{name}\""),
924 )),
925 }
926 }
927}
928
929impl BranchDetail {
930 /// Lend a book out: mark it lent, work out when it is due back, and write
931 /// the loan into the row's notes.
932 ///
933 /// The write is the one a table's save makes: the rows are read through the
934 /// same context the page was rendered from, the table's own `serialize`
935 /// turns them back into JSONL, and `Context::write` replaces the file
936 /// through a temporary one. The rows are validated first and a write that
937 /// would introduce an error is refused, because an action has no cell to
938 /// show the error beside.
939 ///
940 /// The note is the loan's, so it replaces whatever the row said. An example
941 /// that appended would grow its own committed data file every time the
942 /// action was tried.
943 fn lend(fields: &Fields, args: &ViewArgs, ctx: &Context) -> Result<String, ApiError> {
944 let title = args.get_or("title", "");
945 let borrower = fields.text("borrower");
946 let days = fields.integer("days")?;
947 let from = fields.date("from")?;
948 let condition = fields.text("condition").to_lowercase();
949
950 if borrower.is_empty() {
951 return Err(ApiError::bad_request("a loan needs a borrower"));
952 }
953 if days < 1 {
954 return Err(ApiError::bad_request("a loan is at least one day long"));
955 }
956 let start = days_from_civil(from).ok_or_else(|| {
957 ApiError::bad_request(format!("{from} is not a date in the calendar"))
958 })?;
959 let due = civil_from_days(start + days);
960
961 let mut books: Vec<Book> = ctx.rows(BOOKS_FILE)?;
962 let book = books
963 .iter_mut()
964 .find(|b| b.title == title)
965 .ok_or_else(|| ApiError::bad_request(format!("no book is called \"{title}\"")))?;
966 if book.lent.unwrap_or(false) {
967 return Err(ApiError::bad_request(format!("\"{title}\" is already out")));
968 }
969 book.lent = Some(true);
970 book.due = due.clone();
971 book.notes = format!("Lent to {borrower} on {from}, {condition}.");
972
973 let problems = Books.validate(&books, ctx)?;
974 if let Some(first) = problems.first() {
975 return Err(ApiError::bad_request(format!(
976 "the loan would leave row {} in a state the table refuses: {}",
977 first.line, first.message
978 )));
979 }
980
981 let text = Books
982 .serialize(&books)
983 .map_err(|e| ApiError::server(format!("could not serialize {BOOKS_FILE}: {e}")))?;
984 ctx.write(BOOKS_FILE, &text)?;
985
986 Ok(format!("\"{title}\" is out to {borrower} until {due}."))
987 }Sourcepub fn iter(&self) -> impl Iterator<Item = (&str, &str)>
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)>
Every key and value, in order, including those no parameter declares.