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}