Skip to main content

tablo_core/schema/fields/builders/
choices.rs

1//! The choice builder's modifiers: its options, how they render, and where they load from.
2
3use std::sync::Arc;
4
5use super::{
6    super::{super::condition::Watched, ChoiceControl, ControlKind, choice},
7    ChoiceField, IntoOptions, OptionSource,
8};
9use crate::values::FormScalar;
10
11impl<F> ChoiceField<F> {
12    fn choice_mut(&mut self) -> &mut ChoiceControl {
13        match &mut self.0.control {
14            ControlKind::Choice(choice) => choice,
15            _ => unreachable!("a ChoiceField holds a choice control"),
16        }
17    }
18
19    /// Static options: values that are their own label
20    /// (`["draft", "published"]`), `(value, label)` pairs whose label is a string or a
21    /// [`Text`](crate::Text) (`[("draft", text!("status-draft"))]`), or a
22    /// [`#[derive(Options)]`](crate::Options) enum's
23    /// [`options()`](crate::schema::Options::options).
24    pub fn options(mut self, options: impl IntoOptions) -> Self {
25        self.choice_mut().options = options.into_options();
26        self
27    }
28
29    /// Takes several options: a checkbox per option, posting the field's key once per checked
30    /// box. A record form's many-to-many field (`Vec<K>` with `#[form(relationship = R)]`) is one;
31    /// [`parse_list`](crate::values::parse_list) reads one in an action's input.
32    ///
33    /// It offers at most [`MAX_RELATIONSHIP_OPTIONS`](crate::schema::MAX_RELATIONSHIP_OPTIONS)
34    /// records: past them it lists none but the chosen ones, and refuses a submission choosing
35    /// another. A record form's edit re-checks only the keys the record does not link yet.
36    /// `searchable` filters the listed options as the user types. A condition cannot watch it,
37    /// it cannot depend on another field, and its `default` is a list.
38    pub fn multiple(mut self) -> Self {
39        self.choice_mut().multiple = true;
40        self
41    }
42
43    /// Renders a radio per option rather than a `<select>`, so every option shows without a
44    /// click: for a few options, such as a status or a visibility. A [`multiple`](Self::multiple)
45    /// choice keeps its checkboxes.
46    ///
47    /// The options load as a select's do, and past the option cap only the chosen one lists.
48    /// [`searchable`](Self::searchable) filters the listed options as the user types. A radio
49    /// choice cannot [depend on](Self::depends_on) another field, since the browser lists its
50    /// options once, but another choice can depend on it.
51    ///
52    /// ```rust
53    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
54    /// # struct Post { #[key] #[auto] id: uuid::Uuid, visibility: String }
55    /// # use tablo_core::{Field, lens};
56    /// Field::choice(lens!(Post.visibility))
57    ///     .options(vec![
58    ///         ("public".to_string(), "Public".to_string()),
59    ///         ("private".to_string(), "Private".to_string()),
60    ///     ])
61    ///     .radio();
62    /// ```
63    pub fn radio(mut self) -> Self {
64        self.choice_mut().layout = choice::Layout::Radio;
65        self
66    }
67
68    /// Renders a button per option, pressed while its option is chosen: a compact
69    /// [`radio`](Self::radio) list, or with [`multiple`](Self::multiple), a compact checkbox
70    /// list. It takes the same modifiers and limits as [`radio`](Self::radio).
71    ///
72    /// ```rust
73    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
74    /// # struct Task { #[key] #[auto] id: uuid::Uuid, priority: String }
75    /// # use tablo_core::{Field, lens};
76    /// Field::choice(lens!(Task.priority))
77    ///     .options(["low", "normal", "high"])
78    ///     .buttons();
79    /// ```
80    pub fn buttons(mut self) -> Self {
81        self.choice_mut().layout = choice::Layout::Buttons;
82        self
83    }
84
85    /// Refuse a submission choosing fewer than `count` options. A [`multiple`](Self::multiple)
86    /// choice's blank answer is the empty list, which no other rule refuses, so `min_items(1)` is
87    /// how one requires a choice. Only the server checks it. A choice that is not `multiple` fails
88    /// the mount, as does a minimum above `max_items` or above the count of static options.
89    pub fn min_items(mut self, count: usize) -> Self {
90        self.choice_mut().bounds.min = Some(count);
91        self
92    }
93
94    /// Refuse a submission choosing more than `count` options; [`min_items`](Self::min_items)
95    /// says which choices take it.
96    pub fn max_items(mut self, count: usize) -> Self {
97        self.choice_mut().bounds.max = Some(count);
98        self
99    }
100
101    /// Filters options as the user types, fetching from the relationship past the option cap.
102    pub fn searchable(mut self) -> Self {
103        self.choice_mut().searchable = true;
104        self
105    }
106
107    /// Loads options from a related source's tenant-scoped query, each valued by its primary key
108    /// and labelled by the source's [`label`](OptionSource::label), degrading to type-to-search
109    /// past the option cap.
110    ///
111    /// A resource's options carry its [`record_title`](crate::ResourceDef::record_title), the
112    /// title its detail page shows. [`relationship_labelled`](Self::relationship_labelled)
113    /// labels one field's options otherwise.
114    ///
115    /// - Options come from the related resource's tenant-scoped query and follow its policy: the
116    ///   list is empty and the field shows "not available" unless the related resource's policy
117    ///   allows `ViewAny`, and each record must pass its `View`.
118    /// - A submitted key must be one of those records, so a hand-crafted POST cannot point at
119    ///   another tenant's row. The write checks the key again inside its transaction, so a record
120    ///   deleted, moved to another tenant or hidden since the form validated refuses the write with
121    ///   the same field error.
122    /// - At most 200 options load. Past that, a `.searchable()` choice turns into type-to-search
123    ///   against `GET {list_url}/-/options`, which searches the related table's `searchable()`
124    ///   columns; a non-searchable choice shows an error instead. `.searchable()` also filters a
125    ///   short list as you type. Without JavaScript the plain select remains.
126    pub fn relationship<R>(mut self) -> Self
127    where
128        R: OptionSource + 'static,
129    {
130        self.choice_mut().relationship = Some(choice::Relationship::new::<R>(R::label));
131        self
132    }
133
134    /// Offers only the related rows whose column `column` equals the value `parent` posts: the
135    /// cities of the chosen country. The browser fetches the options again when `parent` changes,
136    /// and a submission must name a row of the posted parent value. A blank parent offers none.
137    ///
138    /// `column` belongs to the [`relationship`](Self::relationship)'s source model; mounting
139    /// refuses another model, a choice without a relationship, and a `parent` the schema does not
140    /// place.
141    ///
142    /// - The form renders the options of the parent's current value, and none while it is blank.
143    ///   When the parent changes, the browser fetches the new options from `GET
144    ///   {list_url}/-/options` and keeps the current choice only if they still offer it. A choice
145    ///   they drop changes too, so a choice depending on it, or a condition watching it, follows.
146    /// - A submission must name a record of the parent value it posts, or, on an edit that does not
147    ///   post the parent, of its stored value. The write checks the key again inside its
148    ///   transaction, as for any relationship.
149    /// - The 200-option cap counts the parent's records. Past it, a `.searchable()` dependent
150    ///   choice searches among them, and any other offers no option. A failed fetch offers none
151    ///   either.
152    /// - Without JavaScript the options stay those of the value the page rendered with, and a
153    ///   submission naming another value's record is refused.
154    /// - `column` must belong to the relationship's model, and the parent must be placed in the
155    ///   same schema: mounting refuses either mistake. Only a resource's form serves the options,
156    ///   so mounting also refuses a dependent choice in an action's input.
157    /// - A multiple choice neither depends on another field nor narrows one: mounting refuses both.
158    pub fn depends_on<M, T>(
159        mut self,
160        parent: &impl Watched<F>,
161        column: impl Into<toasty::stmt::Path<M, T>>,
162    ) -> Self
163    where
164        M: toasty::schema::Model + Send + Sync + 'static,
165        T: FormScalar + toasty::stmt::IntoExpr<T> + Send + Sync + 'static,
166    {
167        let column: toasty::stmt::Path<M, T> = column.into();
168        let field = crate::toasty_compat::model::field::<M>(
169            &crate::toasty_compat::model::ModelPath::of(&column),
170        )
171        .ok()
172        .map(|field| field.name);
173        self.choice_mut().parent = Some(choice::Parent {
174            watched: parent.key().to_string(),
175            model: std::any::TypeId::of::<M>(),
176            column: field,
177            scope: Arc::new(move |value: &str| {
178                T::parse_form(value)
179                    .ok()
180                    .map(|value| column.clone().eq(value))
181            }),
182        });
183        self
184    }
185
186    /// Loads options as [`relationship`](Self::relationship) does, labelling each by `label`
187    /// instead of the source's label: to tell apart records that share a title. The option's
188    /// record loads without its relations.
189    pub fn relationship_labelled<R>(
190        mut self,
191        label: impl Fn(&R::Model) -> String + Send + Sync + 'static,
192    ) -> Self
193    where
194        R: OptionSource + 'static,
195    {
196        self.choice_mut().relationship = Some(
197            choice::Relationship::new::<R>(move |_cx: &topcoat::context::Cx, record: &R::Model| {
198                label(record)
199            })
200            .with_custom_label(),
201        );
202        self
203    }
204}