Skip to main content

table_editor/
schema.rs

1//! The column schema a table sends to the browser.
2//!
3//! The schema is data, not code: it carries everything the editor needs to
4//! render and validate a table, so the browser holds no per-repo knowledge. It
5//! is rebuilt on every GET, which lets a table bake in anything derived from a
6//! sibling table—an option list, a dependent option map, a column width—rather
7//! than asking the browser to compute it.
8//!
9//! A `Schema` and its `Column`s are built through constructors rather than
10//! filled in field by field, so a column that the browser could not render
11//! cannot be described: the three column types that need data of their own—
12//! `select`, `computed`, and `map`—take it as a constructor argument.
13
14use std::collections::BTreeMap;
15
16use serde::Serialize;
17
18/// One table's presentation: the columns, how a new row starts, and any
19/// completion lists the columns draw on.
20///
21/// The table's route segment and heading are not set here. They come from the
22/// table's own `name` and `title`, which the server fills in on the way out, so
23/// the two cannot disagree.
24#[derive(Debug, Clone, PartialEq, Serialize)]
25pub struct Schema {
26    table: String,
27    title: String,
28    #[serde(skip_serializing_if = "is_false")]
29    sortable: bool,
30    columns: Vec<Column>,
31    new_row: NewRow,
32    datalists: BTreeMap<String, Datalist>,
33}
34
35impl Schema {
36    pub fn new(columns: impl IntoIterator<Item = Column>) -> Self {
37        Self {
38            table: String::new(),
39            title: String::new(),
40            sortable: false,
41            columns: columns.into_iter().collect(),
42            new_row: NewRow::default(),
43            datalists: BTreeMap::new(),
44        }
45    }
46
47    /// Let the browser sort the view by any column. This is a view setting
48    /// only: writes always send rows in their stored order, and drag reordering
49    /// is disabled while a sort is active. Leave it off on a table whose row
50    /// order is itself meaningful.
51    pub fn sortable(mut self) -> Self {
52        self.sortable = true;
53        self
54    }
55
56    pub fn new_row(mut self, new_row: NewRow) -> Self {
57        self.new_row = new_row;
58        self
59    }
60
61    pub fn datalist(mut self, name: impl Into<String>, list: Datalist) -> Self {
62        self.datalists.insert(name.into(), list);
63        self
64    }
65
66    /// Stamp the schema with the table it describes.
67    pub(crate) fn identify(&mut self, table: &str, title: &str) {
68        self.table.clear();
69        self.table.push_str(table);
70        self.title.clear();
71        self.title.push_str(title);
72    }
73}
74
75/// How the browser renders and edits one column.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum ColumnType {
79    /// A single-line value.
80    String,
81    /// A single-line value that grows to fill the row.
82    Text,
83    /// A single-line value whose spacing is significant, so it is not trimmed.
84    SpacedString,
85    /// A numeric value, stored as a number rather than a string.
86    Number,
87    /// A true-or-false value, stored as a JSON boolean. A bundle gives the
88    /// cell an unset state beside the two and writes it as an absent field
89    /// rather than as `false`, so a row nobody has answered is told apart from
90    /// one answered no.
91    Boolean,
92    /// A value chosen from `options`, or from `options_by` when the list
93    /// depends on another column.
94    Select,
95    /// A read-only value taken from the row's derivation by `from`.
96    Computed,
97    /// A key-to-value object, rendered as one chip per entry.
98    Map,
99}
100
101/// One column of a table.
102#[derive(Debug, Clone, PartialEq, Serialize)]
103pub struct Column {
104    field: String,
105    label: String,
106    #[serde(rename = "type")]
107    kind: ColumnType,
108    #[serde(skip_serializing_if = "is_false")]
109    allow_empty: bool,
110    #[serde(skip_serializing_if = "is_false")]
111    wide: bool,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    width_ch: Option<u16>,
114    #[serde(skip_serializing_if = "Vec::is_empty")]
115    options: Vec<SelectOption>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    options_by: Option<OptionsBy>,
118    #[serde(skip_serializing_if = "Vec::is_empty")]
119    cascades_to: Vec<String>,
120    #[serde(skip_serializing_if = "is_false")]
121    numeric_value: bool,
122    #[serde(skip_serializing_if = "is_false")]
123    int_only: bool,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    datalist: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    from: Option<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    speak: Option<Speak>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    href: Option<String>,
132    #[serde(flatten)]
133    map: Option<MapSpec>,
134}
135
136impl Column {
137    fn base(field: impl Into<String>, label: impl Into<String>, kind: ColumnType) -> Self {
138        Self {
139            field: field.into(),
140            label: label.into(),
141            kind,
142            allow_empty: false,
143            wide: false,
144            width_ch: None,
145            options: Vec::new(),
146            options_by: None,
147            cascades_to: Vec::new(),
148            numeric_value: false,
149            int_only: false,
150            datalist: None,
151            from: None,
152            speak: None,
153            href: None,
154            map: None,
155        }
156    }
157
158    pub fn string(field: impl Into<String>, label: impl Into<String>) -> Self {
159        Self::base(field, label, ColumnType::String)
160    }
161
162    pub fn text(field: impl Into<String>, label: impl Into<String>) -> Self {
163        Self::base(field, label, ColumnType::Text)
164    }
165
166    pub fn spaced_string(field: impl Into<String>, label: impl Into<String>) -> Self {
167        Self::base(field, label, ColumnType::SpacedString)
168    }
169
170    pub fn number(field: impl Into<String>, label: impl Into<String>) -> Self {
171        Self::base(field, label, ColumnType::Number)
172    }
173
174    /// A true-or-false value, which a bundle also lets stand unset. See
175    /// [`ColumnType::Boolean`] for what unset is written as.
176    pub fn boolean(field: impl Into<String>, label: impl Into<String>) -> Self {
177        Self::base(field, label, ColumnType::Boolean)
178    }
179
180    /// A select over a fixed list of options.
181    pub fn select(
182        field: impl Into<String>,
183        label: impl Into<String>,
184        options: impl IntoIterator<Item = impl Into<SelectOption>>,
185    ) -> Self {
186        Self {
187            options: options.into_iter().map(Into::into).collect(),
188            ..Self::base(field, label, ColumnType::Select)
189        }
190    }
191
192    /// A select whose options depend on another column's value.
193    pub fn select_by(
194        field: impl Into<String>,
195        label: impl Into<String>,
196        options_by: OptionsBy,
197    ) -> Self {
198        Self {
199            options_by: Some(options_by),
200            ..Self::base(field, label, ColumnType::Select)
201        }
202    }
203
204    /// A read-only column showing `from` out of each row's derived object.
205    pub fn computed(
206        field: impl Into<String>,
207        label: impl Into<String>,
208        from: impl Into<String>,
209    ) -> Self {
210        Self {
211            from: Some(from.into()),
212            ..Self::base(field, label, ColumnType::Computed)
213        }
214    }
215
216    /// A key-to-value object rendered as one chip per entry.
217    pub fn map(field: impl Into<String>, label: impl Into<String>, spec: MapSpec) -> Self {
218        Self {
219            map: Some(spec),
220            ..Self::base(field, label, ColumnType::Map)
221        }
222    }
223
224    /// Offer a blank choice on a select whose value may legitimately be unset.
225    pub fn allow_empty(mut self) -> Self {
226        self.allow_empty = true;
227        self
228    }
229
230    /// Let the column take the remaining width of the row.
231    pub fn wide(mut self) -> Self {
232        self.wide = true;
233        self
234    }
235
236    /// Store the chosen option's value as a number rather than a string.
237    pub fn numeric_value(mut self) -> Self {
238        self.numeric_value = true;
239        self
240    }
241
242    /// Confine a number column to whole numbers: a bundle rounds what the cell
243    /// is given and steps it by one.
244    pub fn int_only(mut self) -> Self {
245        self.int_only = true;
246        self
247    }
248
249    /// A fixed width in characters, for a column whose content the browser
250    /// cannot measure.
251    pub fn width_ch(mut self, width_ch: u16) -> Self {
252        self.width_ch = Some(width_ch);
253        self
254    }
255
256    /// Columns whose value is cleared when this one changes, because their
257    /// options are drawn from it.
258    pub fn cascades_to(mut self, fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
259        self.cascades_to = fields.into_iter().map(Into::into).collect();
260        self
261    }
262
263    /// The completion list this column's input offers, named among the
264    /// schema's `datalists`.
265    pub fn datalist(mut self, name: impl Into<String>) -> Self {
266        self.datalist = Some(name.into());
267        self
268    }
269
270    /// Where the cell's play button sends the value to be spoken.
271    pub fn speak(mut self, speak: Speak) -> Self {
272        self.speak = Some(speak);
273        self
274    }
275
276    /// Show this column's value as a link, to the URL held by another field of
277    /// the same row.
278    ///
279    /// It is honoured where a cell is read rather than edited—a `computed`
280    /// column of a table, every column of a view—and ignored elsewhere, since
281    /// a cell being typed into cannot also be a link. A bundle opens it in a
282    /// new tab, and follows only `http:` and `https:`, so a row carrying
283    /// something else in that field is text rather than a way to run it.
284    pub fn href(mut self, field: impl Into<String>) -> Self {
285        self.href = Some(field.into());
286        self
287    }
288}
289
290/// One choice in a select. `label` is what the browser shows when it differs
291/// from the stored value.
292#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
293pub struct SelectOption {
294    pub value: String,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub label: Option<String>,
297}
298
299impl SelectOption {
300    pub fn new(value: impl Into<String>) -> Self {
301        Self {
302            value: value.into(),
303            label: None,
304        }
305    }
306
307    pub fn labelled(value: impl Into<String>, label: impl Into<String>) -> Self {
308        Self {
309            value: value.into(),
310            label: Some(label.into()),
311        }
312    }
313}
314
315impl From<&str> for SelectOption {
316    fn from(value: &str) -> Self {
317        Self::new(value)
318    }
319}
320
321impl From<String> for SelectOption {
322    fn from(value: String) -> Self {
323        Self::new(value)
324    }
325}
326
327impl From<&String> for SelectOption {
328    fn from(value: &String) -> Self {
329        Self::new(value.as_str())
330    }
331}
332
333/// A select whose options depend on another column: the browser looks the row's
334/// value of `field` up in `options`. A value with no entry offers no choices.
335#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
336pub struct OptionsBy {
337    pub field: String,
338    pub options: BTreeMap<String, Vec<SelectOption>>,
339}
340
341impl OptionsBy {
342    pub fn new(field: impl Into<String>) -> Self {
343        Self {
344            field: field.into(),
345            options: BTreeMap::new(),
346        }
347    }
348
349    pub fn with(
350        mut self,
351        value: impl Into<String>,
352        options: impl IntoIterator<Item = impl Into<SelectOption>>,
353    ) -> Self {
354        self.insert(value, options);
355        self
356    }
357
358    pub fn insert(
359        &mut self,
360        value: impl Into<String>,
361        options: impl IntoIterator<Item = impl Into<SelectOption>>,
362    ) {
363        self.options
364            .insert(value.into(), options.into_iter().map(Into::into).collect());
365    }
366}
367
368/// Where a cell's play button sends its value. A bundle substitutes the
369/// URL-encoded cell value for `{value}`, and lets a `localStorage` entry under
370/// `storage_key` override the origin.
371#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
372pub struct Speak {
373    pub url: String,
374    pub storage_key: String,
375}
376
377impl Speak {
378    pub fn new(url: impl Into<String>, storage_key: impl Into<String>) -> Self {
379        Self {
380            url: url.into(),
381            storage_key: storage_key.into(),
382        }
383    }
384}
385
386/// What a map's chips put before the value: the key's label, or the key
387/// itself.
388///
389/// A key that stands for a long title makes for tall rows once a cell holds
390/// several entries, and the label is in the panel either way, so a table whose
391/// keys are short codes can show those instead.
392#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
393#[serde(rename_all = "lowercase")]
394pub enum ChipContent {
395    /// The key's label, falling back to the key where it has none.
396    #[default]
397    Label,
398    /// The key as it is stored.
399    Key,
400}
401
402impl ChipContent {
403    /// Whether this is what a chip shows unless a table says otherwise, which
404    /// is the case the JSON leaves out.
405    fn is_label(&self) -> bool {
406        matches!(self, ChipContent::Label)
407    }
408}
409
410/// The extra description a `map` column carries. A bundle renders one chip per
411/// entry as `key: value`, drops an entry whose value is cleared, and writes a
412/// map that empties as an absent field.
413///
414/// A key option carries a label of its own where the stored key is not what a
415/// reader should see—a code beside the title it stands for, say—so
416/// `key_options` takes the same `{ value, label }` pairs a select's options do,
417/// and a bundle shows the label in place of the value it stores.
418///
419/// Both option lists are always written, empty or not, because together with
420/// the two `allow_new_` flags they are what the control is made of. This is
421/// unlike a select's `options`, which is omitted when empty because a select
422/// carries `options_by` instead.
423#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
424pub struct MapSpec {
425    pub key_label: String,
426    pub value_label: String,
427    pub key_options: Vec<SelectOption>,
428    pub value_options: Vec<SelectOption>,
429    /// Let a key be typed that `key_options` does not list.
430    pub allow_new_keys: bool,
431    /// Let a value be typed that `value_options` does not list, which makes
432    /// those options suggestions rather than the whole choice.
433    pub allow_new_values: bool,
434    /// What a chip shows before the value. Unlike the fields above, which
435    /// together are what the control is made of, this is a preference about
436    /// how a cell reads, so the usual case is left out of the JSON.
437    #[serde(skip_serializing_if = "ChipContent::is_label")]
438    pub chip: ChipContent,
439}
440
441impl MapSpec {
442    pub fn new(key_label: impl Into<String>, value_label: impl Into<String>) -> Self {
443        Self {
444            key_label: key_label.into(),
445            value_label: value_label.into(),
446            key_options: Vec::new(),
447            value_options: Vec::new(),
448            allow_new_keys: false,
449            allow_new_values: false,
450            chip: ChipContent::Label,
451        }
452    }
453
454    /// The keys a bundle offers. A plain string is a key that shows itself; a
455    /// [`SelectOption::labelled`] key is shown by its label and stored by its
456    /// value.
457    pub fn key_options(mut self, keys: impl IntoIterator<Item = impl Into<SelectOption>>) -> Self {
458        self.key_options = keys.into_iter().map(Into::into).collect();
459        self
460    }
461
462    pub fn value_options(
463        mut self,
464        values: impl IntoIterator<Item = impl Into<SelectOption>>,
465    ) -> Self {
466        self.value_options = values.into_iter().map(Into::into).collect();
467        self
468    }
469
470    pub fn allow_new_keys(mut self) -> Self {
471        self.allow_new_keys = true;
472        self
473    }
474
475    pub fn allow_new_values(mut self) -> Self {
476        self.allow_new_values = true;
477        self
478    }
479
480    /// Show the stored key on a chip rather than its label, which keeps a cell
481    /// of several entries short where the labels are long. The panel that
482    /// edits the entries shows both either way.
483    pub fn chips_show_key(mut self) -> Self {
484        self.chip = ChipContent::Key;
485        self
486    }
487}
488
489/// How a new row starts: take `defaults`, then overwrite each field named in
490/// `carry_forward` with the value the last row that has one carries, so a run
491/// of rows sharing a genre or a publisher is typed once.
492#[derive(Debug, Clone, PartialEq, Default, Serialize)]
493pub struct NewRow {
494    pub defaults: serde_json::Map<String, serde_json::Value>,
495    pub carry_forward: Vec<String>,
496}
497
498impl NewRow {
499    pub fn new() -> Self {
500        Self::default()
501    }
502
503    pub fn with(mut self, field: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
504        self.defaults.insert(field.into(), value.into());
505        self
506    }
507
508    pub fn carry_forward(mut self, fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
509        self.carry_forward = fields.into_iter().map(Into::into).collect();
510        self
511    }
512}
513
514/// A completion list a column's input draws on, in one of two forms: a fixed
515/// list the server computed, or one the browser computes live from the rows on
516/// screen.
517#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
518#[serde(untagged)]
519pub enum Datalist {
520    /// A list the server built, typically from a sibling table.
521    Fixed { options: Vec<String> },
522    /// A list built from the rows on screen.
523    Live { from_rows: FromRows },
524}
525
526impl Datalist {
527    pub fn fixed(options: impl IntoIterator<Item = impl Into<String>>) -> Self {
528        Self::Fixed {
529            options: options.into_iter().map(Into::into).collect(),
530        }
531    }
532
533    pub fn from_rows(
534        fields: impl IntoIterator<Item = impl Into<String>>,
535        separator: impl Into<String>,
536    ) -> Self {
537        Self::Live {
538            from_rows: FromRows {
539                fields: fields.into_iter().map(Into::into).collect(),
540                separator: separator.into(),
541            },
542        }
543    }
544}
545
546/// A completion list the browser builds from the rows on screen: trim each
547/// named field, drop the row when the first field is blank, join the non-blank
548/// ones with `separator`, then dedupe and sort.
549#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
550pub struct FromRows {
551    pub fields: Vec<String>,
552    pub separator: String,
553}
554
555fn is_false(value: &bool) -> bool {
556    !*value
557}
558
559#[cfg(test)]
560mod tests {
561    use serde_json::json;
562
563    use super::*;
564
565    /// A worked example: a select with a fixed option list that cascades into a
566    /// dependent one, a computed width, a wide text column, and no datalists.
567    fn books_schema() -> Schema {
568        let mut schema = Schema::new([
569            Column::string("title", "Title"),
570            Column::select("genre", "Genre", ["Reference", "Travel"])
571                .allow_empty()
572                .cascades_to(["subgenre"]),
573            Column::select_by(
574                "subgenre",
575                "Subgenre",
576                OptionsBy::new("genre")
577                    .with("Reference", ["Natural History"])
578                    .with("Travel", ["Field Guides"]),
579            )
580            .allow_empty()
581            .width_ch(18),
582            Column::select("format", "Format", ["Hardcover", "Paperback", "Folio"]),
583            Column::number("copies", "Copies").int_only(),
584            Column::boolean("lent", "Lent"),
585            Column::text("comment", "Comment").wide(),
586        ])
587        .new_row(
588            NewRow::new()
589                .with("title", "")
590                .with("genre", "")
591                .with("subgenre", "")
592                .with("format", "Paperback")
593                .with("copies", 1)
594                .with("comment", ""),
595        );
596        schema.identify("books", "Books");
597        schema
598    }
599
600    #[test]
601    fn schema_serializes_to_the_documented_shape() {
602        assert_eq!(
603            serde_json::to_value(books_schema()).unwrap(),
604            json!({
605              "table": "books",
606              "title": "Books",
607              "columns": [
608                { "field": "title", "label": "Title", "type": "string" },
609                { "field": "genre", "label": "Genre", "type": "select", "allow_empty": true,
610                  "options": [{ "value": "Reference" }, { "value": "Travel" }],
611                  "cascades_to": ["subgenre"] },
612                { "field": "subgenre", "label": "Subgenre", "type": "select", "allow_empty": true,
613                  "width_ch": 18,
614                  "options_by": { "field": "genre",
615                    "options": { "Reference": [{ "value": "Natural History" }],
616                                 "Travel": [{ "value": "Field Guides" }] } } },
617                { "field": "format", "label": "Format", "type": "select",
618                  "options": [{ "value": "Hardcover" }, { "value": "Paperback" }, { "value": "Folio" }] },
619                { "field": "copies", "label": "Copies", "type": "number", "int_only": true },
620                { "field": "lent", "label": "Lent", "type": "boolean" },
621                { "field": "comment", "label": "Comment", "type": "text", "wide": true }
622              ],
623              "new_row": { "defaults": { "title": "", "genre": "", "subgenre": "", "format": "Paperback",
624                                         "copies": 1, "comment": "" },
625                           "carry_forward": [] },
626              "datalists": {}
627            })
628        );
629    }
630
631    #[test]
632    fn identify_replaces_whatever_was_there() {
633        let mut schema = Schema::new([]);
634        schema.identify("first", "First");
635        schema.identify("second", "Second");
636        let json = serde_json::to_value(schema).unwrap();
637        assert_eq!(json["table"], "second");
638        assert_eq!(json["title"], "Second");
639    }
640
641    #[test]
642    fn map_column_serializes_to_the_documented_shape() {
643        let column = Column::map(
644            "shelved",
645            "Shelved",
646            MapSpec::new("Branch", "Count")
647                .key_options(["Central", "Eastside", "Harbour"])
648                .value_options(["None", "One", "Several"]),
649        );
650
651        assert_eq!(
652            serde_json::to_value(column).unwrap(),
653            json!({ "field": "shelved", "label": "Shelved", "type": "map",
654                    "key_label": "Branch", "value_label": "Count",
655                    "key_options": [{ "value": "Central" }, { "value": "Eastside" },
656                                    { "value": "Harbour" }],
657                    "value_options": [{ "value": "None" }, { "value": "One" },
658                                      { "value": "Several" }],
659                    "allow_new_keys": false, "allow_new_values": false })
660        );
661    }
662
663    #[test]
664    fn a_chip_shows_the_key_only_where_a_table_asks_for_it() {
665        let by_label = serde_json::to_value(MapSpec::new("Branch", "Count")).unwrap();
666        assert!(by_label.get("chip").is_none());
667
668        let by_key =
669            serde_json::to_value(MapSpec::new("Branch", "Count").chips_show_key()).unwrap();
670        assert_eq!(by_key["chip"], "key");
671    }
672
673    #[test]
674    fn a_maps_option_lists_are_written_even_when_empty() {
675        assert_eq!(
676            serde_json::to_value(MapSpec::new("Branch", "Count")).unwrap(),
677            json!({ "key_label": "Branch", "value_label": "Count",
678                    "key_options": [], "value_options": [],
679                    "allow_new_keys": false, "allow_new_values": false })
680        );
681
682        // Unlike a select, whose options are omitted when it has none.
683        let select = serde_json::to_value(Column::select_by(
684            "subgenre",
685            "Subgenre",
686            OptionsBy::new("genre"),
687        ))
688        .unwrap();
689        assert!(select.get("options").is_none());
690    }
691
692    #[test]
693    fn a_map_key_can_show_a_label_beside_the_stored_value() {
694        let spec = MapSpec::new("Branch", "Count")
695            .key_options([SelectOption::labelled("hb", "Harbour"), "Central".into()]);
696
697        assert_eq!(
698            serde_json::to_value(spec).unwrap()["key_options"],
699            json!([{ "value": "hb", "label": "Harbour" }, { "value": "Central" }])
700        );
701    }
702
703    #[test]
704    fn a_map_can_take_values_its_options_do_not_list() {
705        let open = serde_json::to_value(
706            MapSpec::new("Branch", "Note")
707                .value_options(["None"])
708                .allow_new_values(),
709        )
710        .unwrap();
711        assert_eq!(open["allow_new_values"], true);
712        assert_eq!(open["value_options"], json!([{ "value": "None" }]));
713    }
714
715    #[test]
716    fn column_types_serialize_in_kebab_case() {
717        for (column, name) in [
718            (Column::string("f", "F"), "string"),
719            (Column::text("f", "F"), "text"),
720            (Column::spaced_string("f", "F"), "spaced-string"),
721            (Column::number("f", "F"), "number"),
722            (Column::boolean("f", "F"), "boolean"),
723            (Column::select("f", "F", ["a"]), "select"),
724            (Column::select_by("f", "F", OptionsBy::new("g")), "select"),
725            (Column::computed("f", "F", "k"), "computed"),
726            (Column::map("f", "F", MapSpec::new("K", "V")), "map"),
727        ] {
728            assert_eq!(serde_json::to_value(column).unwrap()["type"], name);
729        }
730    }
731
732    #[test]
733    fn int_only_is_omitted_unless_set() {
734        let plain = serde_json::to_value(Column::number("copies", "Copies")).unwrap();
735        assert!(plain.get("int_only").is_none());
736
737        let whole = serde_json::to_value(Column::number("copies", "Copies").int_only()).unwrap();
738        assert_eq!(whole["int_only"], true);
739    }
740
741    #[test]
742    fn a_select_always_carries_one_source_of_options() {
743        let fixed = serde_json::to_value(Column::select("f", "F", ["a"])).unwrap();
744        assert!(fixed.get("options").is_some());
745        assert!(fixed.get("options_by").is_none());
746
747        let dependent =
748            serde_json::to_value(Column::select_by("f", "F", OptionsBy::new("g"))).unwrap();
749        assert!(dependent.get("options").is_none());
750        assert!(dependent.get("options_by").is_some());
751    }
752
753    #[test]
754    fn absent_options_are_omitted_rather_than_null() {
755        let json = serde_json::to_value(Column::string("title", "Title")).unwrap();
756        let object = json.as_object().unwrap();
757        assert_eq!(
758            object.keys().map(String::as_str).collect::<Vec<_>>(),
759            ["field", "label", "type"]
760        );
761    }
762
763    #[test]
764    fn computed_column_names_its_derived_key() {
765        let json = serde_json::to_value(Column::computed("age", "Age", "age_years")).unwrap();
766        assert_eq!(json["type"], "computed");
767        assert_eq!(json["from"], "age_years");
768    }
769
770    #[test]
771    fn a_column_names_the_datalist_its_input_offers() {
772        let column = Column::string("author_last", "Author").datalist("author-names");
773        assert_eq!(
774            serde_json::to_value(column).unwrap()["datalist"],
775            "author-names"
776        );
777    }
778
779    #[test]
780    fn labelled_and_numeric_options_carry_both_halves() {
781        let column = Column::select(
782            "month",
783            "Month",
784            [SelectOption::labelled("1", "January (1)")],
785        )
786        .numeric_value();
787        let json = serde_json::to_value(column).unwrap();
788        assert_eq!(
789            json["options"][0],
790            json!({ "value": "1", "label": "January (1)" })
791        );
792        assert_eq!(json["numeric_value"], true);
793    }
794
795    #[test]
796    fn speak_carries_the_url_template_and_storage_key() {
797        let column = Column::string("pronunciation", "Pronunciation").speak(Speak::new(
798            "http://127.0.0.1:8765/say?text={value}",
799            "speech-service-url",
800        ));
801        assert_eq!(
802            serde_json::to_value(column).unwrap()["speak"],
803            json!({ "url": "http://127.0.0.1:8765/say?text={value}",
804                    "storage_key": "speech-service-url" })
805        );
806    }
807
808    #[test]
809    fn both_datalist_forms_serialize_by_their_own_key() {
810        let schema = Schema::new([])
811            .datalist("genre-names", Datalist::fixed(["Reference", "Travel"]))
812            .datalist(
813                "author-names",
814                Datalist::from_rows(["author_first", "author_last"], " "),
815            );
816
817        assert_eq!(
818            serde_json::to_value(schema).unwrap()["datalists"],
819            json!({
820                "genre-names": { "options": ["Reference", "Travel"] },
821                "author-names": { "from_rows": { "fields": ["author_first", "author_last"],
822                                                 "separator": " " } }
823            })
824        );
825    }
826
827    #[test]
828    fn sortable_is_omitted_unless_set() {
829        let plain = serde_json::to_value(Schema::new([])).unwrap();
830        assert!(plain.get("sortable").is_none());
831
832        let sorted = serde_json::to_value(Schema::new([]).sortable()).unwrap();
833        assert_eq!(sorted["sortable"], true);
834    }
835
836    #[test]
837    fn new_row_carries_defaults_and_carried_fields() {
838        let new_row = NewRow::new()
839            .with("genre", "Reference")
840            .with("copies", 1)
841            .carry_forward(["genre", "subgenre", "format"]);
842        assert_eq!(
843            serde_json::to_value(new_row).unwrap(),
844            json!({ "defaults": { "genre": "Reference", "copies": 1 },
845                    "carry_forward": ["genre", "subgenre", "format"] })
846        );
847    }
848}