Skip to main content

table_editor/
page.rs

1//! What a view answers with: tables of rows, grids of cards, and detail pages.
2//!
3//! A table of rows reuses the column [`Schema`](crate::Schema), so the browser
4//! renders it with what it already knows. A card and a detail page cannot work
5//! that way: a card says what one thing is and how it stands, and a detail page
6//! is one thing with its parts arranged around it, neither of which is a grid
7//! of cells. So they have a vocabulary of their own, built here through
8//! constructors, which is what keeps a repository from describing a page the
9//! browser could not draw.
10//!
11//! Nothing here names a colour, a width, or a class. A status carries a word
12//! and a [`Tone`] from a closed set, and how a tone is drawn—in the dark theme
13//! and the light one—is the bundle's business. That is the line the whole
14//! vocabulary is drawn along: a repository says what a thing is and how it
15//! stands, and the page says what that looks like.
16
17use std::collections::BTreeMap;
18use std::fmt;
19
20use serde::Serialize;
21
22use crate::error::ApiError;
23use crate::schema::{Column, SelectOption};
24
25fn is_false(value: &bool) -> bool {
26    !*value
27}
28
29// ── Tables of rows ──────────────────────────────────────────────────────────
30
31/// One run of rows under a heading of its own.
32///
33/// Columns belong to a section rather than to the view, so two sections can
34/// differ: a section of what is overdue wants a column of how late, and a
35/// section of what is merely out does not. Sections that should line up are
36/// given the same columns.
37#[derive(Debug, Clone, Serialize)]
38pub struct Section {
39    #[serde(skip_serializing_if = "Option::is_none")]
40    heading: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    note: Option<String>,
43    columns: Vec<Column>,
44    rows: Vec<serde_json::Value>,
45}
46
47impl Section {
48    pub fn new(columns: impl IntoIterator<Item = Column>) -> Self {
49        Self {
50            heading: None,
51            note: None,
52            columns: columns.into_iter().collect(),
53            rows: Vec::new(),
54        }
55    }
56
57    pub fn heading(mut self, heading: impl Into<String>) -> Self {
58        self.heading = Some(heading.into());
59        self
60    }
61
62    pub fn note(mut self, note: impl Into<String>) -> Self {
63        self.note = Some(note.into());
64        self
65    }
66
67    /// The rows themselves, which may be a repository's own types: whatever
68    /// serializes to an object keyed by the fields the columns name.
69    ///
70    /// A row that cannot be serialized is a 500 naming the section, since a
71    /// page that quietly dropped a row would be worse than one that did not
72    /// render.
73    pub fn rows<T: Serialize>(
74        mut self,
75        rows: impl IntoIterator<Item = T>,
76    ) -> Result<Self, ApiError> {
77        self.rows = rows
78            .into_iter()
79            .map(|row| serde_json::to_value(row))
80            .collect::<Result<Vec<_>, _>>()
81            .map_err(|e| {
82                let what = self.heading.as_deref().unwrap_or("a section");
83                ApiError::server(format!("could not serialize the rows of {what}: {e}"))
84            })?;
85        Ok(self)
86    }
87}
88
89// ── How a thing stands ──────────────────────────────────────────────────────
90
91/// How a status reads, from the five the bundle draws.
92///
93/// A repository says which of these a status is and never what colour it
94/// takes: the colours differ between the two themes, and they are chosen for
95/// contrast against the page and against a card, which is a decision that has
96/// to be made once in the bundle rather than in every repository.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
98#[serde(rename_all = "lowercase")]
99pub enum Tone {
100    /// What was hoped for: a revision asked for, an acceptance.
101    Good,
102    /// Worth watching: something out with someone else, something waiting.
103    Warning,
104    /// What wants doing: idle, refused, overdue.
105    Bad,
106    /// A fact about the thing that is neither good nor bad. A card whose
107    /// statuses are all neutral reads quieter than the rest.
108    Neutral,
109    /// Something to know rather than something to act on.
110    Info,
111}
112
113/// A word for how a thing stands, and how that word reads.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115pub struct Status {
116    word: String,
117    tone: Tone,
118}
119
120impl Status {
121    pub fn new(word: impl Into<String>, tone: Tone) -> Self {
122        Self {
123            word: word.into(),
124            tone,
125        }
126    }
127}
128
129/// A link to another of this app's views, asked a particular question.
130///
131/// It is the view's name and the arguments to ask it with, not an address: the
132/// address a view is reached at is the bundle's to write, and one written here
133/// would have to know where the app is served from.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
135pub struct ViewLink {
136    view: String,
137    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
138    args: BTreeMap<String, String>,
139}
140
141impl ViewLink {
142    pub fn new(view: impl Into<String>) -> Self {
143        Self {
144            view: view.into(),
145            args: BTreeMap::new(),
146        }
147    }
148
149    /// One of the arguments the other view is asked with.
150    pub fn arg(mut self, key: impl Into<String>, value: impl fmt::Display) -> Self {
151        self.args.insert(key.into(), value.to_string());
152        self
153    }
154}
155
156// ── Cards ───────────────────────────────────────────────────────────────────
157
158/// One label-and-value line of a card.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
160pub struct CardRow {
161    label: String,
162    value: String,
163}
164
165/// One thing, as a card says it: how it stands, what it is called, a few facts
166/// about it, and where to read more.
167///
168/// Everything but the title is optional, and what is not given is left out
169/// rather than drawn empty, because a card is read down the page and a blank
170/// line in one is noise.
171#[derive(Debug, Clone, Serialize)]
172pub struct Card {
173    #[serde(skip_serializing_if = "Vec::is_empty")]
174    statuses: Vec<Status>,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    identifier: Option<String>,
177    title: String,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    subtitle: Option<String>,
180    #[serde(skip_serializing_if = "Vec::is_empty")]
181    rows: Vec<CardRow>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    sentence: Option<String>,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    link: Option<ViewLink>,
186}
187
188impl Card {
189    pub fn new(title: impl Into<String>) -> Self {
190        Self {
191            statuses: Vec::new(),
192            identifier: None,
193            title: title.into(),
194            subtitle: None,
195            rows: Vec::new(),
196            sentence: None,
197            link: None,
198        }
199    }
200
201    /// How the thing stands. A card may carry more than one: a story whose
202    /// last answer asked for revisions and which is also out somewhere else is
203    /// both at once, and saying only one of the two would be a lie.
204    pub fn status(mut self, status: Status) -> Self {
205        self.statuses.push(status);
206        self
207    }
208
209    /// The short name the thing is filed under, shown beside the status.
210    pub fn identifier(mut self, identifier: impl Into<String>) -> Self {
211        self.identifier = Some(identifier.into());
212        self
213    }
214
215    /// The facts that go under the title, as one line.
216    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
217        self.subtitle = Some(subtitle.into());
218        self
219    }
220
221    /// One line of the card's own table: a label on the left, a value on the
222    /// right. The value is anything that prints, so a count needs no
223    /// conversion.
224    pub fn row(mut self, label: impl Into<String>, value: impl fmt::Display) -> Self {
225        self.rows.push(CardRow {
226            label: label.into(),
227            value: value.to_string(),
228        });
229        self
230    }
231
232    /// A sentence about the card as a whole, for what a label and a value
233    /// cannot say.
234    pub fn sentence(mut self, sentence: impl Into<String>) -> Self {
235        self.sentence = Some(sentence.into());
236        self
237    }
238
239    /// The view the whole card is a link to.
240    pub fn link(mut self, link: ViewLink) -> Self {
241        self.link = Some(link);
242        self
243    }
244}
245
246/// A run of cards under a heading, with the count of them.
247#[derive(Debug, Clone, Serialize)]
248pub struct CardGroup {
249    heading: String,
250    cards: Vec<Card>,
251}
252
253impl CardGroup {
254    pub fn new(heading: impl Into<String>) -> Self {
255        Self {
256            heading: heading.into(),
257            cards: Vec::new(),
258        }
259    }
260
261    pub fn card(mut self, card: Card) -> Self {
262        self.cards.push(card);
263        self
264    }
265
266    pub fn cards(mut self, cards: impl IntoIterator<Item = Card>) -> Self {
267        self.cards.extend(cards);
268        self
269    }
270
271    pub fn is_empty(&self) -> bool {
272        self.cards.is_empty()
273    }
274}
275
276// ── Detail pages ────────────────────────────────────────────────────────────
277
278/// Which column of a detail page a section sits in. It is the `column` the
279/// payload carries and nothing a repository names: a section is built by
280/// [`DetailSection::main`] or [`DetailSection::side`], which is what decides
281/// it.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
283#[serde(rename_all = "lowercase")]
284pub(crate) enum DetailColumn {
285    Main,
286    Side,
287}
288
289/// One thing in full: a header saying what it is and how it stands, and
290/// sections of rows around it.
291#[derive(Debug, Clone, Serialize)]
292pub struct Detail {
293    title: String,
294    #[serde(skip_serializing_if = "Vec::is_empty")]
295    statuses: Vec<Status>,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    subtitle: Option<String>,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    back: Option<ViewLink>,
300    sections: Vec<DetailSection>,
301}
302
303impl Detail {
304    pub fn new(title: impl Into<String>) -> Self {
305        Self {
306            title: title.into(),
307            statuses: Vec::new(),
308            subtitle: None,
309            back: None,
310            sections: Vec::new(),
311        }
312    }
313
314    /// How the thing stands, as a card would say it, and as often as a card
315    /// may say it.
316    pub fn status(mut self, status: Status) -> Self {
317        self.statuses.push(status);
318        self
319    }
320
321    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
322        self.subtitle = Some(subtitle.into());
323        self
324    }
325
326    /// The view this page was reached from, which the header offers as the way
327    /// back. Its title is the bundle's to write, from the app it already knows.
328    pub fn back(mut self, link: ViewLink) -> Self {
329        self.back = Some(link);
330        self
331    }
332
333    pub fn section(mut self, section: DetailSection) -> Self {
334        self.sections.push(section);
335        self
336    }
337
338    /// Every action a button on this page offers, with the arguments the
339    /// button's own form carries.
340    ///
341    /// The router checks a posted action against these, so an action is only
342    /// reachable where the page being looked at offers it, and only about the
343    /// row the button was built for.
344    pub(crate) fn actions(&self) -> impl Iterator<Item = (&str, &BTreeMap<String, String>)> {
345        self.sections
346            .iter()
347            .flat_map(|section| section.rows.iter())
348            .flat_map(|row| row.buttons.iter())
349            .filter_map(Button::offer)
350    }
351}
352
353/// One run of rows under a heading, in one of the page's two columns.
354#[derive(Debug, Clone, Serialize)]
355pub struct DetailSection {
356    heading: String,
357    column: DetailColumn,
358    #[serde(skip_serializing_if = "Option::is_none")]
359    note: Option<String>,
360    #[serde(skip_serializing_if = "is_false")]
361    numbered: bool,
362    #[serde(skip_serializing_if = "is_false")]
363    collapsed_on_phone: bool,
364    rows: Vec<DetailRow>,
365}
366
367impl DetailSection {
368    /// A section of the page's main column, which is where what the reader came
369    /// for goes.
370    pub fn main(heading: impl Into<String>) -> Self {
371        Self::in_column(heading, DetailColumn::Main)
372    }
373
374    /// A section of the page's side column, which is where what is worth
375    /// knowing but not acting on goes. On a phone there is one column, and the
376    /// sections keep the order they were given in.
377    pub fn side(heading: impl Into<String>) -> Self {
378        Self::in_column(heading, DetailColumn::Side)
379    }
380
381    fn in_column(heading: impl Into<String>, column: DetailColumn) -> Self {
382        Self {
383            heading: heading.into(),
384            column,
385            note: None,
386            numbered: false,
387            collapsed_on_phone: false,
388            rows: Vec::new(),
389        }
390    }
391
392    /// A sentence under the heading, which is also what a section with no rows
393    /// says instead of them.
394    pub fn note(mut self, note: impl Into<String>) -> Self {
395        self.note = Some(note.into());
396        self
397    }
398
399    /// Number the rows, for a section whose order is a ranking: the first row
400    /// is the one to do something about.
401    pub fn numbered(mut self) -> Self {
402        self.numbered = true;
403        self
404    }
405
406    /// Start the section shut on a phone, where a page of everything at once
407    /// is a page of scrolling. It is open wherever there is room for it beside
408    /// the main column.
409    pub fn collapsed_on_phone(mut self) -> Self {
410        self.collapsed_on_phone = true;
411        self
412    }
413
414    pub fn row(mut self, row: DetailRow) -> Self {
415        self.rows.push(row);
416        self
417    }
418
419    pub fn rows(mut self, rows: impl IntoIterator<Item = DetailRow>) -> Self {
420        self.rows.extend(rows);
421        self
422    }
423}
424
425/// One row of a section: what it is, a few facts about it, and what can be
426/// done with it.
427#[derive(Debug, Clone, Serialize)]
428pub struct DetailRow {
429    title: String,
430    #[serde(skip_serializing_if = "Option::is_none")]
431    link: Option<String>,
432    #[serde(skip_serializing_if = "Vec::is_empty")]
433    facts: Vec<String>,
434    #[serde(skip_serializing_if = "Vec::is_empty")]
435    notes: Vec<String>,
436    #[serde(skip_serializing_if = "Vec::is_empty")]
437    buttons: Vec<Button>,
438}
439
440impl DetailRow {
441    pub fn new(title: impl Into<String>) -> Self {
442        Self {
443            title: title.into(),
444            link: None,
445            facts: Vec::new(),
446            notes: Vec::new(),
447            buttons: Vec::new(),
448        }
449    }
450
451    /// A page out of the app the title links to. It is followed only when it is
452    /// an absolute `http:` or `https:` URL, the rule a table's `href` follows,
453    /// and shown as text otherwise.
454    pub fn link(mut self, url: impl Into<String>) -> Self {
455        self.link = Some(url.into());
456        self
457    }
458
459    /// One short fact about the row. The facts are drawn as one line, in the
460    /// order they were given, separated by commas.
461    pub fn fact(mut self, fact: impl fmt::Display) -> Self {
462        self.facts.push(fact.to_string());
463        self
464    }
465
466    /// One thing worth saying about the row at more length than a fact. The
467    /// notes are drawn as a line of their own under the facts.
468    pub fn note(mut self, note: impl Into<String>) -> Self {
469        self.notes.push(note.into());
470        self
471    }
472
473    pub fn button(mut self, button: Button) -> Self {
474        self.buttons.push(button);
475        self
476    }
477}
478
479// ── Buttons and the actions they reach ──────────────────────────────────────
480
481/// Something a row offers: a page to open, a form to fill in, or a reason it
482/// cannot be done yet.
483#[derive(Debug, Clone, Serialize)]
484pub struct Button {
485    label: String,
486    #[serde(flatten)]
487    kind: ButtonKind,
488}
489
490#[derive(Debug, Clone, Serialize)]
491#[serde(tag = "type", rename_all = "lowercase")]
492enum ButtonKind {
493    Link { url: String },
494    Form(Form),
495    Disabled { reason: String },
496}
497
498impl Button {
499    /// A page out of the app, opened in a tab of its own. The URL is held to
500    /// the same rule a row's own link is.
501    pub fn link(label: impl Into<String>, url: impl Into<String>) -> Self {
502        Self {
503            label: label.into(),
504            kind: ButtonKind::Link { url: url.into() },
505        }
506    }
507
508    /// A form the button reveals, which writes when it is saved.
509    pub fn form(label: impl Into<String>, form: Form) -> Self {
510        Self {
511            label: label.into(),
512            kind: ButtonKind::Form(form),
513        }
514    }
515
516    /// A button that cannot be pressed, and why. It is drawn rather than left
517    /// out, so that what a page will one day do is visible on it.
518    pub fn disabled(label: impl Into<String>, reason: impl Into<String>) -> Self {
519        Self {
520            label: label.into(),
521            kind: ButtonKind::Disabled {
522                reason: reason.into(),
523            },
524        }
525    }
526
527    /// What this button offers to write: the action's name and the arguments
528    /// its form carries. A button that writes nothing offers nothing.
529    fn offer(&self) -> Option<(&str, &BTreeMap<String, String>)> {
530        match &self.kind {
531            ButtonKind::Form(form) => Some((form.action(), form.args())),
532            _ => None,
533        }
534    }
535}
536
537/// What a form asks for and what writes it.
538///
539/// `action` is the name the write is reached under, which the repository reads
540/// back in [`ViewLogic::act`](crate::ViewLogic::act). `args` are the values the
541/// form carries rather than asks for: a form built per row knows which row it
542/// belongs to, and says so here rather than making the reader pick the row
543/// again.
544#[derive(Debug, Clone, Serialize)]
545pub struct Form {
546    action: String,
547    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
548    args: BTreeMap<String, String>,
549    fields: Vec<Field>,
550}
551
552impl Form {
553    pub fn new(action: impl Into<String>) -> Self {
554        Self {
555            action: action.into(),
556            args: BTreeMap::new(),
557            fields: Vec::new(),
558        }
559    }
560
561    /// One argument the form carries, which is how a form built per row says
562    /// which row it belongs to.
563    ///
564    /// It is added to the view's own arguments on the way to the action, and
565    /// it is what the action is checked against: a write is refused unless a
566    /// button on the page offers that action with exactly these arguments. A
567    /// key the view itself declares as a parameter is refused, since a form
568    /// that answered one of the page's own questions would be checked against
569    /// a different page than the one it is on.
570    pub fn arg(mut self, key: impl Into<String>, value: impl fmt::Display) -> Self {
571        self.args.insert(key.into(), value.to_string());
572        self
573    }
574
575    pub fn field(mut self, field: Field) -> Self {
576        self.fields.push(field);
577        self
578    }
579
580    pub fn fields(mut self, fields: impl IntoIterator<Item = Field>) -> Self {
581        self.fields.extend(fields);
582        self
583    }
584
585    pub(crate) fn action(&self) -> &str {
586        &self.action
587    }
588
589    pub(crate) fn args(&self) -> &BTreeMap<String, String> {
590        &self.args
591    }
592}
593
594/// How a field is asked for. It is the `type` the payload carries and nothing a
595/// repository names: a field is built by one of [`Field`]'s constructors, which
596/// is what decides it.
597#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
598#[serde(rename_all = "kebab-case")]
599pub(crate) enum FieldKind {
600    Text,
601    Number,
602    Date,
603    OneOf,
604}
605
606/// One thing a form asks for.
607#[derive(Debug, Clone, Serialize)]
608pub struct Field {
609    key: String,
610    label: String,
611    #[serde(rename = "type")]
612    kind: FieldKind,
613    #[serde(skip_serializing_if = "Vec::is_empty")]
614    options: Vec<SelectOption>,
615    #[serde(skip_serializing_if = "Option::is_none")]
616    default: Option<String>,
617}
618
619impl Field {
620    pub fn text(key: impl Into<String>, label: impl Into<String>) -> Self {
621        Self::of_kind(key, label, FieldKind::Text)
622    }
623
624    pub fn number(key: impl Into<String>, label: impl Into<String>) -> Self {
625        Self::of_kind(key, label, FieldKind::Number)
626    }
627
628    /// A date, asked for as `YYYY-MM-DD`.
629    pub fn date(key: impl Into<String>, label: impl Into<String>) -> Self {
630        Self::of_kind(key, label, FieldKind::Date)
631    }
632
633    /// One of a few answers, all of them on screen at once. The options take
634    /// the same shape a select's do, so an answer can be stored under one word
635    /// and read under another.
636    pub fn one_of(
637        key: impl Into<String>,
638        label: impl Into<String>,
639        options: impl IntoIterator<Item = impl Into<SelectOption>>,
640    ) -> Self {
641        let mut field = Self::of_kind(key, label, FieldKind::OneOf);
642        field.options = options.into_iter().map(Into::into).collect();
643        field
644    }
645
646    fn of_kind(key: impl Into<String>, label: impl Into<String>, kind: FieldKind) -> Self {
647        Self {
648            key: key.into(),
649            label: label.into(),
650            kind,
651            options: Vec::new(),
652            default: None,
653        }
654    }
655
656    /// What the field is filled in with before anything is typed. It travels as
657    /// text, as every answer does.
658    pub fn default(mut self, value: impl fmt::Display) -> Self {
659        self.default = Some(value.to_string());
660        self
661    }
662}
663
664#[cfg(test)]
665mod tests {
666    use serde::Serialize;
667    use serde_json::json;
668
669    use super::*;
670
671    #[test]
672    fn a_section_omits_what_it_was_not_given() {
673        let bare = Section::new([Column::string("title", "Title")]);
674        assert_eq!(
675            serde_json::to_value(&bare).unwrap(),
676            json!({ "columns": [{ "field": "title", "label": "Title", "type": "string" }],
677                    "rows": [] })
678        );
679
680        let full = Section::new([Column::string("title", "Title")])
681            .heading("Out")
682            .note("Due back this week.")
683            .rows(vec![json!({ "title": "A Field Guide to Moss" })])
684            .unwrap();
685        assert_eq!(
686            serde_json::to_value(&full).unwrap(),
687            json!({ "heading": "Out", "note": "Due back this week.",
688                    "columns": [{ "field": "title", "label": "Title", "type": "string" }],
689                    "rows": [{ "title": "A Field Guide to Moss" }] })
690        );
691    }
692
693    #[test]
694    fn a_section_takes_a_repositorys_own_type_for_its_rows() {
695        #[derive(Serialize)]
696        struct Loan {
697            title: &'static str,
698            days: u32,
699        }
700
701        let section = Section::new([Column::string("title", "Title")])
702            .rows([Loan {
703                title: "Nine Doors",
704                days: 25,
705            }])
706            .unwrap();
707        assert_eq!(
708            serde_json::to_value(&section).unwrap()["rows"],
709            json!([{ "title": "Nine Doors", "days": 25 }])
710        );
711    }
712
713    #[test]
714    fn a_row_that_cannot_be_serialized_names_the_section_it_was_in() {
715        struct Awkward;
716        impl Serialize for Awkward {
717            fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
718                Err(serde::ser::Error::custom("no"))
719            }
720        }
721
722        let failure = Section::new([Column::string("title", "Title")])
723            .heading("Out")
724            .rows([Awkward])
725            .unwrap_err();
726        assert_eq!(failure.status, 500);
727        assert!(failure.message.contains("Out"), "{}", failure.message);
728    }
729
730    #[test]
731    fn a_tone_is_one_of_five_words() {
732        let words: Vec<serde_json::Value> = [
733            Tone::Good,
734            Tone::Warning,
735            Tone::Bad,
736            Tone::Neutral,
737            Tone::Info,
738        ]
739        .iter()
740        .map(|tone| serde_json::to_value(tone).unwrap())
741        .collect();
742        assert_eq!(
743            words,
744            vec!["good", "warning", "bad", "neutral", "info"]
745                .into_iter()
746                .map(serde_json::Value::from)
747                .collect::<Vec<_>>()
748        );
749    }
750
751    #[test]
752    fn a_bare_card_is_a_title_and_nothing_else() {
753        assert_eq!(
754            serde_json::to_value(Card::new("Three Minutes to Midnight")).unwrap(),
755            json!({ "title": "Three Minutes to Midnight" })
756        );
757    }
758
759    #[test]
760    fn a_card_serializes_to_the_documented_shape() {
761        let card = Card::new("Three Minutes to Midnight")
762            .status(Status::new("Submitted", Tone::Warning))
763            .identifier("midnight")
764            .subtitle("4,200 words, science fiction, v3")
765            .row("Clarkesworld", "6m 3w 2d")
766            .row("Open markets", 7)
767            .sentence("Tied up at Clarkesworld, which reads one story at a time.")
768            .link(ViewLink::new("story").arg("codename", "midnight"));
769
770        assert_eq!(
771            serde_json::to_value(&card).unwrap(),
772            json!({
773                "statuses": [{ "word": "Submitted", "tone": "warning" }],
774                "identifier": "midnight",
775                "title": "Three Minutes to Midnight",
776                "subtitle": "4,200 words, science fiction, v3",
777                "rows": [{ "label": "Clarkesworld", "value": "6m 3w 2d" },
778                         { "label": "Open markets", "value": "7" }],
779                "sentence": "Tied up at Clarkesworld, which reads one story at a time.",
780                "link": { "view": "story", "args": { "codename": "midnight" } }
781            })
782        );
783    }
784
785    #[test]
786    fn a_card_can_stand_two_ways_at_once() {
787        let card = Card::new("Locus of Control")
788            .status(Status::new("Revisions requested", Tone::Good))
789            .status(Status::new("Submitted", Tone::Warning));
790        assert_eq!(
791            serde_json::to_value(&card).unwrap()["statuses"],
792            json!([{ "word": "Revisions requested", "tone": "good" },
793                   { "word": "Submitted", "tone": "warning" }])
794        );
795    }
796
797    #[test]
798    fn a_view_link_with_no_arguments_carries_none() {
799        assert_eq!(
800            serde_json::to_value(ViewLink::new("stories")).unwrap(),
801            json!({ "view": "stories" })
802        );
803    }
804
805    #[test]
806    fn a_group_counts_what_is_in_it() {
807        let empty = CardGroup::new("Idle");
808        assert!(empty.is_empty());
809        assert_eq!(
810            serde_json::to_value(&empty).unwrap(),
811            json!({ "heading": "Idle", "cards": [] })
812        );
813
814        let filled = CardGroup::new("Idle").cards([Card::new("Teare"), Card::new("Nine Doors")]);
815        assert!(!filled.is_empty());
816        assert_eq!(
817            serde_json::to_value(&filled).unwrap()["cards"]
818                .as_array()
819                .unwrap()
820                .len(),
821            2
822        );
823    }
824
825    #[test]
826    fn a_detail_page_serializes_to_the_documented_shape() {
827        let detail = Detail::new("Three Minutes to Midnight")
828            .status(Status::new("Submitted", Tone::Warning))
829            .subtitle("4,200 words, science fiction, v3")
830            .back(ViewLink::new("stories"))
831            .section(
832                DetailSection::main("Send next").numbered().row(
833                    DetailRow::new("Clarkesworld")
834                        .link("https://example.invalid/guidelines")
835                        .fact("$0.12 a word")
836                        .fact("rank 1")
837                        .note("Reads one story at a time")
838                        .button(Button::link(
839                            "Guidelines",
840                            "https://example.invalid/guidelines",
841                        ))
842                        .button(Button::disabled("Draft cover letter", "Not built yet"))
843                        .button(Button::form(
844                            "Record submission",
845                            Form::new("record-submission")
846                                .arg("market", "clarkesworld")
847                                .field(Field::number("draft", "Draft sent").default(3))
848                                .field(Field::date("sent", "Date sent").default("2026-09-21")),
849                        )),
850                ),
851            )
852            .section(
853                DetailSection::side("Shut for now")
854                    .collapsed_on_phone()
855                    .note("Nothing on the list is taking submissions."),
856            );
857
858        assert_eq!(
859            serde_json::to_value(&detail).unwrap(),
860            json!({
861                "title": "Three Minutes to Midnight",
862                "statuses": [{ "word": "Submitted", "tone": "warning" }],
863                "subtitle": "4,200 words, science fiction, v3",
864                "back": { "view": "stories" },
865                "sections": [
866                    { "heading": "Send next", "column": "main", "numbered": true,
867                      "rows": [
868                        { "title": "Clarkesworld",
869                          "link": "https://example.invalid/guidelines",
870                          "facts": ["$0.12 a word", "rank 1"],
871                          "notes": ["Reads one story at a time"],
872                          "buttons": [
873                            { "label": "Guidelines", "type": "link",
874                              "url": "https://example.invalid/guidelines" },
875                            { "label": "Draft cover letter", "type": "disabled",
876                              "reason": "Not built yet" },
877                            { "label": "Record submission", "type": "form",
878                              "action": "record-submission",
879                              "args": { "market": "clarkesworld" },
880                              "fields": [
881                                { "key": "draft", "label": "Draft sent",
882                                  "type": "number", "default": "3" },
883                                { "key": "sent", "label": "Date sent",
884                                  "type": "date", "default": "2026-09-21" }] }] }] },
885                    { "heading": "Shut for now", "column": "side",
886                      "collapsed_on_phone": true,
887                      "note": "Nothing on the list is taking submissions.",
888                      "rows": [] }
889                ]
890            })
891        );
892    }
893
894    #[test]
895    fn a_detail_page_names_the_actions_its_buttons_offer() {
896        let detail = Detail::new("Three Minutes to Midnight")
897            .section(
898                DetailSection::main("Out now").row(DetailRow::new("Clarkesworld").button(
899                    Button::form("Record the answer", Form::new("record-answer")),
900                )),
901            )
902            .section(
903                DetailSection::side("Send next")
904                    .row(
905                        DetailRow::new("Asimov's")
906                            .button(Button::link("Guidelines", "https://example.invalid"))
907                            .button(Button::disabled("Draft cover letter", "Not built yet"))
908                            .button(Button::form(
909                                "Record submission",
910                                Form::new("record-submission").arg("market", "asimovs"),
911                            )),
912                    )
913                    .row(DetailRow::new("Nothing to do here")),
914            );
915
916        // An offer is the action and the arguments the button's own form
917        // carries, which is what pins a write to the row it was offered from.
918        let offered: Vec<(&str, Vec<(&str, &str)>)> = detail
919            .actions()
920            .map(|(action, args)| {
921                (
922                    action,
923                    args.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(),
924                )
925            })
926            .collect();
927        assert_eq!(
928            offered,
929            vec![
930                ("record-answer", vec![]),
931                ("record-submission", vec![("market", "asimovs")]),
932            ]
933        );
934    }
935
936    #[test]
937    fn a_field_serializes_to_the_documented_shape() {
938        assert_eq!(
939            serde_json::to_value(Field::text("borrower", "Borrower")).unwrap(),
940            json!({ "key": "borrower", "label": "Borrower", "type": "text" })
941        );
942        assert_eq!(
943            serde_json::to_value(Field::number("days", "Days").default(21)).unwrap(),
944            json!({ "key": "days", "label": "Days", "type": "number", "default": "21" })
945        );
946        assert_eq!(
947            serde_json::to_value(
948                Field::one_of(
949                    "result",
950                    "What came back",
951                    [SelectOption::new("Rejected"), SelectOption::new("Accepted")]
952                )
953                .default("Rejected")
954            )
955            .unwrap(),
956            json!({ "key": "result", "label": "What came back", "type": "one-of",
957                    "options": [{ "value": "Rejected" }, { "value": "Accepted" }],
958                    "default": "Rejected" })
959        );
960    }
961
962    #[test]
963    fn a_form_carries_the_arguments_of_the_row_it_was_built_for() {
964        let form = Form::new("lend")
965            .arg("title", "Nine Doors")
966            .arg("copies", 2);
967        assert_eq!(
968            serde_json::to_value(&form).unwrap(),
969            json!({ "action": "lend",
970                    "args": { "copies": "2", "title": "Nine Doors" },
971                    "fields": [] })
972        );
973    }
974}