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    /// and so is its `Vec` with `#[form(options = T)]`, stored in a list column;
32    /// [`parse_list`](crate::values::parse_list) reads one in an action's input.
33    ///
34    /// It offers at most [`MAX_RELATIONSHIP_OPTIONS`](crate::schema::MAX_RELATIONSHIP_OPTIONS)
35    /// records: past them it lists none but the chosen ones, and refuses a submission choosing
36    /// another. A record form's edit re-checks only the keys the record does not link yet.
37    /// `searchable` filters the listed options as the user types. A condition cannot watch it,
38    /// it cannot depend on another field, and its `default` is a list.
39    pub fn multiple(mut self) -> Self {
40        self.choice_mut().multiple = true;
41        self
42    }
43
44    /// Takes any values the user types, as tags: a [`multiple`](Self::multiple) choice whose
45    /// [`options`](Self::options), if any, are suggestions.
46    ///
47    /// - It renders a checkbox per suggestion and per value it holds, checked while it holds it,
48    ///   then a text input adding values, several at once separated by commas.
49    /// - Each value is trimmed, and a blank one or a repeat is dropped, so a value cannot hold a
50    ///   comma. Any other value is accepted: [`max_items`](Self::max_items) caps how many.
51    /// - Mounting refuses it on a [`relationship`](Self::relationship)'s choice, whose values name
52    ///   records.
53    ///
54    /// A record form's `Vec<String>` marked `#[form(tags)]` is one.
55    pub fn tags(mut self) -> Self {
56        let choice = self.choice_mut();
57        choice.multiple = true;
58        choice.tags = true;
59        self
60    }
61
62    /// Renders a radio per option rather than a `<select>`, so every option shows without a
63    /// click: for a few options, such as a status or a visibility. A [`multiple`](Self::multiple)
64    /// choice keeps its checkboxes.
65    ///
66    /// The options load as a select's do, and past the option cap only the chosen one lists.
67    /// [`searchable`](Self::searchable) filters the listed options as the user types. A radio
68    /// choice cannot [depend on](Self::depends_on) another field, since the browser lists its
69    /// options once, but another choice can depend on it.
70    ///
71    /// ```rust
72    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
73    /// # struct Post { #[key] #[auto] id: uuid::Uuid, visibility: String }
74    /// # use tablo_core::{Field, lens};
75    /// Field::choice(lens!(Post.visibility))
76    ///     .options(vec![
77    ///         ("public".to_string(), "Public".to_string()),
78    ///         ("private".to_string(), "Private".to_string()),
79    ///     ])
80    ///     .radio();
81    /// ```
82    pub fn radio(mut self) -> Self {
83        self.choice_mut().layout = choice::Layout::Radio;
84        self
85    }
86
87    /// Renders a button per option, pressed while its option is chosen: a compact
88    /// [`radio`](Self::radio) list, or with [`multiple`](Self::multiple), a compact checkbox
89    /// list. It takes the same modifiers and limits as [`radio`](Self::radio).
90    ///
91    /// ```rust
92    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
93    /// # struct Task { #[key] #[auto] id: uuid::Uuid, priority: String }
94    /// # use tablo_core::{Field, lens};
95    /// Field::choice(lens!(Task.priority))
96    ///     .options(["low", "normal", "high"])
97    ///     .buttons();
98    /// ```
99    pub fn buttons(mut self) -> Self {
100        self.choice_mut().layout = choice::Layout::Buttons;
101        self
102    }
103
104    /// Refuse a submission choosing fewer than `count` options. A [`multiple`](Self::multiple)
105    /// choice's blank answer is the empty list, which no other rule refuses, so `min_items(1)` is
106    /// how one requires a choice. Only the server checks it. A choice that is not `multiple` fails
107    /// the mount, as does a minimum above `max_items` or above the count of static options.
108    pub fn min_items(mut self, count: usize) -> Self {
109        self.choice_mut().bounds.min = Some(count);
110        self
111    }
112
113    /// Refuse a submission choosing more than `count` options; [`min_items`](Self::min_items)
114    /// says which choices take it.
115    pub fn max_items(mut self, count: usize) -> Self {
116        self.choice_mut().bounds.max = Some(count);
117        self
118    }
119
120    /// Filters options as the user types, fetching from the relationship past the option cap.
121    pub fn searchable(mut self) -> Self {
122        self.choice_mut().searchable = true;
123        self
124    }
125
126    /// Loads options from a related source's tenant-scoped query, each valued by its primary key
127    /// and labelled by the source's [`label`](OptionSource::label), degrading to type-to-search
128    /// past the option cap.
129    ///
130    /// A resource's options carry its [`record_title`](crate::ResourceDef::record_title), the
131    /// title its detail page shows. [`relationship_labelled`](Self::relationship_labelled)
132    /// labels one field's options otherwise.
133    ///
134    /// - Options come from the related resource's tenant-scoped query and follow its policy: the
135    ///   list is empty and the field shows "not available" unless the related resource's policy
136    ///   allows `ViewAny`, and each record must pass its `View`.
137    /// - A submitted key must be one of those records, so a hand-crafted POST cannot point at
138    ///   another tenant's row. The write checks the key again inside its transaction, so a record
139    ///   deleted, moved to another tenant or hidden since the form validated refuses the write with
140    ///   the same field error.
141    /// - At most 200 options load. Past that, a `.searchable()` choice turns into type-to-search
142    ///   against `GET {list_url}/-/options`, which searches the related table's `searchable()`
143    ///   columns; a non-searchable choice shows an error instead. `.searchable()` also filters a
144    ///   short list as you type. Without JavaScript the plain select remains.
145    pub fn relationship<R>(mut self) -> Self
146    where
147        R: OptionSource + 'static,
148    {
149        self.choice_mut().relationship = Some(choice::Relationship::new::<R>(R::label));
150        self
151    }
152
153    /// Offers to create the related record from the form: a link beside the select opens the
154    /// [`relationship`](Self::relationship)'s resource's create page in a dialog, and the record it
155    /// saves becomes the choice, while the form keeps what was typed in it.
156    ///
157    /// - The dialog frames the related resource's own create page, so the record passes that
158    ///   resource's policy, tenancy, field access, rules and hooks, as a create from its list does.
159    ///   The link renders only while the panel mounts the resource with a create page and its
160    ///   policy allows `ViewAny` and `Create`, and not while the choice renders disabled.
161    /// - The saved record becomes the choice only when the related resource's scoped query holds
162    ///   it, as its options would; otherwise the dialog says it was created and the choice stays as
163    ///   it was. The submission checks the key as for any relationship.
164    /// - The option shows the record's [`record_title`](crate::ResourceDef::record_title) until the
165    ///   form renders again, even under [`relationship_labelled`](Self::relationship_labelled).
166    /// - The dialog takes the height of the form, up to the screen's, and titles it. In the dialog
167    ///   a [`Section`](crate::Section) has no panel and does not fold, and a section alone in the
168    ///   form shows no title.
169    /// - Without JavaScript the link opens the create page in a new tab, and the record is offered
170    ///   once the form renders again.
171    /// - Mounting refuses a creatable choice that is not a single select over a resource's records:
172    ///   a multiple choice, [radios](Self::radio) or [buttons](Self::buttons), a
173    ///   [dependent](Self::depends_on) or [filtered](Self::options_where) choice, a source that is
174    ///   not a resource, a choice a [condition disables](Self::disabled_when), and a choice in a
175    ///   repeater's rows.
176    pub fn creatable(mut self) -> Self {
177        self.choice_mut().creatable = true;
178        self
179    }
180
181    /// Offers only the related rows whose column `column` equals the value `parent` posts: the
182    /// cities of the chosen country. The browser fetches the options again when `parent` changes,
183    /// and a submission must name a row of the posted parent value. A blank parent offers none.
184    ///
185    /// `column` belongs to the [`relationship`](Self::relationship)'s source model; mounting
186    /// refuses another model, a choice without a relationship, and a `parent` the schema does not
187    /// place.
188    ///
189    /// - The form renders the options of the parent's current value, and none while it is blank.
190    ///   When the parent changes, the browser fetches the new options from `GET
191    ///   {list_url}/-/options` and keeps the current choice only if they still offer it. A choice
192    ///   they drop changes too, so a choice depending on it, or a condition watching it, follows.
193    /// - A submission must name a record of the parent value it posts, or, on an edit that does not
194    ///   post the parent, of its stored value. The write checks the key again inside its
195    ///   transaction, as for any relationship.
196    /// - The 200-option cap counts the parent's records. Past it, a `.searchable()` dependent
197    ///   choice searches among them, and any other offers no option. A failed fetch offers none
198    ///   either.
199    /// - Without JavaScript the options stay those of the value the page rendered with, and a
200    ///   submission naming another value's record is refused.
201    /// - `column` must belong to the relationship's model, and the parent must be placed in the
202    ///   same schema: mounting refuses either mistake. Only a resource's form serves the options,
203    ///   so mounting also refuses a dependent choice in an action's input.
204    /// - A multiple choice neither depends on another field nor narrows one: mounting refuses both.
205    pub fn depends_on<M, T>(
206        mut self,
207        parent: &impl Watched<F>,
208        column: impl Into<toasty::stmt::Path<M, T>>,
209    ) -> Self
210    where
211        M: toasty::schema::Model + Send + Sync + 'static,
212        T: FormScalar + toasty::stmt::IntoExpr<T> + Send + Sync + 'static,
213    {
214        let column: toasty::stmt::Path<M, T> = column.into();
215        let field = crate::toasty_compat::model::field::<M>(
216            &crate::toasty_compat::model::ModelPath::of(&column),
217        )
218        .ok()
219        .map(|field| field.name);
220        self.choice_mut().parent = Some(choice::Parent {
221            watched: parent.key().to_string(),
222            model: std::any::TypeId::of::<M>(),
223            column: field,
224            scope: Arc::new(move |value: &str| {
225                T::parse_form(value)
226                    .ok()
227                    .map(|value| column.clone().eq(value))
228            }),
229        });
230        self
231    }
232
233    /// Offers only the related rows `filter` selects, among those the source's scoped query and a
234    /// [`depends_on`](Self::depends_on) parent offer: the categories without a parent, or the
235    /// users of one role. `filter` builds the predicate for each request, so it may read the
236    /// request's user or tenant.
237    ///
238    /// ```rust
239    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
240    /// # pub struct Category {
241    /// #     #[key] #[auto] pub id: uuid::Uuid,
242    /// #     pub name: String,
243    /// #     #[index] pub parent_id: Option<uuid::Uuid>,
244    /// # }
245    /// # pub struct CategoryResource;
246    /// # impl tablo_core::Resource for CategoryResource {
247    /// #     type Model = Category;
248    /// #     type Form = tablo_core::NoForm<Category>;
249    /// # }
250    /// # use tablo_core::{Field, lens};
251    /// Field::choice(lens!(Category.parent_id))
252    ///     .relationship::<CategoryResource>()
253    ///     .options_where(|_cx| Category::fields().parent_id().is_none());
254    /// ```
255    ///
256    /// - The form, the search past the option cap and a dependent choice's fetch offer only the
257    ///   rows it selects, and a submission must name one: the server checks the key on submit and
258    ///   again inside the write's transaction, so a hand-crafted POST cannot name another row.
259    /// - A [`multiple`](Self::multiple) choice keeps the links it holds to rows the filter drops,
260    ///   since its form never offered them.
261    /// - The predicate must read the relationship's source model; one over another model fails the
262    ///   load, and the field says its options failed to load. Field access does not see the fields
263    ///   it reads: a predicate over a restricted field tells the user which rows pass it.
264    /// - Mounting refuses a filter on a choice without a relationship, and on a
265    ///   [creatable](Self::creatable) choice, whose dialog may save a record the filter drops.
266    pub fn options_where(
267        mut self,
268        filter: impl Fn(&topcoat::context::Cx) -> toasty::stmt::Expr<bool> + Send + Sync + 'static,
269    ) -> Self {
270        self.choice_mut().filter = Some(Arc::new(filter));
271        self
272    }
273
274    /// Loads options as [`relationship`](Self::relationship) does, labelling each by `label`
275    /// instead of the source's label: to tell apart records that share a title. The option's
276    /// record loads without its relations.
277    pub fn relationship_labelled<R>(
278        mut self,
279        label: impl Fn(&R::Model) -> String + Send + Sync + 'static,
280    ) -> Self
281    where
282        R: OptionSource + 'static,
283    {
284        self.choice_mut().relationship = Some(
285            choice::Relationship::new::<R>(move |_cx: &topcoat::context::Cx, record: &R::Model| {
286                label(record)
287            })
288            .with_custom_label(),
289        );
290        self
291    }
292}