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}