Skip to main content

tablo_core/schema/fields/
builders.rs

1//! The typed builders the [`Field`] constructors return, each offering only its control's modifiers
2//! so a modifier on the wrong control does not compile.
3
4use std::{marker::PhantomData, sync::Arc};
5
6pub use self::into::{IntoFormValue, IntoOptions};
7use super::{
8    super::{
9        IntoSchema, OptionSource, Retype, Schema,
10        condition::{Condition, Watched},
11        repeater::RepeaterControl,
12    },
13    ChoiceControl, Control, ControlKind, Field, Kind, Pattern, TextControl,
14};
15#[cfg(feature = "rich-text")]
16use super::{RichTextControl, RichTextField};
17
18mod choices;
19mod comparisons;
20mod files;
21
22/// `label`, `name` and `visible_when`, shared by every builder.
23macro_rules! base_modifiers {
24    ($builder:ident) => {
25        impl $builder {
26            pub(super) fn new(field: Field) -> Self {
27                Self(field, PhantomData)
28            }
29        }
30
31        impl<F> $builder<F> {
32            pub fn label(mut self, label: impl Into<crate::Text>) -> Self {
33                self.0.label = Some(label.into());
34                self
35            }
36
37            pub fn name(&self) -> &str {
38                self.0.name()
39            }
40
41            /// Shows the field only while `watched` posts one of `values`: an option's value, or
42            /// `true` for a checked toggle. A hidden field posts nothing, so an edit keeps its
43            /// stored value and a create takes its blank answer; a field with no blank answer
44            /// cannot be conditional. A field shows under one condition: a later call replaces an
45            /// earlier one, so a field depending on two watched fields sits in a
46            /// [`Section`](crate::Section) guarded by the other.
47            ///
48            /// ```rust
49            /// # #[derive(Debug, Clone, toasty::Model)]
50            /// # struct Customer {
51            /// #     #[key] #[auto] id: uuid::Uuid,
52            /// #     kind: String,
53            /// #     vat_number: Option<String>,
54            /// # }
55            /// # use tablo_core::{Field, Schema};
56            /// let kind = Field::choice(Customer::fields().kind()).options(["person", "company"]);
57            /// let vat = Field::text(Customer::fields().vat_number()).visible_when(&kind, ["company"]);
58            /// Schema::new((kind, vat));
59            /// ```
60            ///
61            /// - The browser shows and hides the field as the watched one changes, with no request.
62            ///   A hidden field's control is disabled, so the browser neither validates nor posts
63            ///   it. Without JavaScript, the fields start as the stored values show them and stay
64            ///   that way.
65            /// - The panel reads a submission the same way and drops a hidden field's posted key,
66            ///   and a file field's carried upload: an edit keeps the stored value, and a create
67            ///   stores the blank answer. A conditional field therefore needs a blank answer, and
68            ///   mounting refuses one without.
69            /// - Mounting also refuses a value the watched field never posts (a choice's option or
70            ///   a toggle's `true` and `false` are checked), and a condition watching a field that
71            ///   another condition hides while the guarded field still shows: place the guarded
72            ///   field inside the block that hides the watched one. A schema also refuses a
73            ///   condition watching a field it does not place, which only a page's or an action's
74            ///   schema can do: a resource's form places every record-form field.
75            /// - A toggle is followed through its checkbox. An app's own checkbox `Control` posts
76            ///   the same `value` checked or not, so a condition cannot follow it.
77            /// - A multiple choice's key holds a list, which no condition value spells: mounting
78            ///   refuses a condition watching one.
79            pub fn visible_when(
80                mut self,
81                watched: &impl Watched<F>,
82                values: impl IntoIterator<Item = impl Into<String>>,
83            ) -> Self {
84                self.0.condition = Some(Condition::new(watched, values));
85                self
86            }
87        }
88
89        impl<F> From<$builder<F>> for Field {
90            fn from(builder: $builder<F>) -> Field {
91                builder.0
92            }
93        }
94
95        impl<F> IntoSchema<F> for $builder<F> {
96            fn into_schema(self) -> Schema<F> {
97                self.0.into_schema().retype()
98            }
99        }
100
101        impl<F> Retype for $builder<F> {
102            type As<G> = $builder<G>;
103
104            fn retype<G>(self) -> $builder<G> {
105                $builder(self.0, PhantomData)
106            }
107        }
108    };
109}
110
111/// The base modifiers, `help`, `disabled` and `custom`, on every builder of one value's control.
112macro_rules! common_modifiers {
113    ($builder:ident) => {
114        base_modifiers!($builder);
115
116        impl<F> $builder<F> {
117            /// Renders `text` under the control, which the control names as its description.
118            pub fn help(mut self, text: impl Into<crate::Text>) -> Self {
119                self.0.help = Some(text.into());
120                self
121            }
122
123            /// Renders the control disabled on the create and edit forms. The server drops a
124            /// value posted for it anyway, so an edit keeps the stored value and a create takes
125            /// the field's `default`, else its blank answer; a field with neither cannot be
126            /// created, and its panel refuses to mount.
127            pub fn disabled(mut self) -> Self {
128                self.0.disabled = super::Disabled::Always;
129                self
130            }
131
132            /// Renders the control disabled on the edit form only: the create form sets the
133            /// value, and no edit changes it.
134            pub fn disabled_on_edit(mut self) -> Self {
135                self.0.disabled = super::Disabled::OnEdit;
136                self
137            }
138
139            /// Renders the field with an app's [`Control`] instead, as [`Field::custom`] does:
140            /// how a record form's control takes a custom input. The field keeps its key and
141            /// label; the replaced control's own modifiers, such as `email`, `unique` or
142            /// `options`, are dropped with it.
143            pub fn custom(mut self, control: impl Control + 'static) -> CustomField<F> {
144                self.0.control = ControlKind::Custom(Arc::new(control));
145                self.0.checkbox = false;
146                CustomField(self.0, PhantomData)
147            }
148        }
149    };
150}
151
152/// Renders the control required, as a panel does for a record-form field with no blank answer.
153#[cfg(test)]
154macro_rules! required_for_tests {
155    ($($builder:ident),*) => {$(
156        impl<F> $builder<F> {
157            pub(crate) fn required(mut self) -> Self {
158                self.0.set_required(true);
159                self
160            }
161        }
162    )*};
163}
164
165#[cfg(test)]
166required_for_tests!(TextField);
167
168/// `default`, on every builder whose control a value fills.
169macro_rules! default_modifier {
170    ($($builder:ident),*) => {$(
171        impl<F> $builder<F> {
172            /// Fills the create form's control with `value`: any
173            /// [`FormScalar`](crate::FormScalar) or a `&str` in the form spelling, or, for a
174            /// [`multiple`](ChoiceField::multiple) choice, an array or `Vec` of them. On a
175            /// [`disabled`](Self::disabled) field it is the value a create stores. A schema
176            /// refuses a default the control never posts: one a typed field does not parse, one
177            /// not among a choice's options, a list on a control posting one value, and one value
178            /// on a multiple choice.
179            ///
180            /// ```rust
181            /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
182            /// # struct Post { #[key] #[auto] id: uuid::Uuid, status: String, views: i64 }
183            /// # use tablo_core::Field;
184            /// Field::choice(Post::fields().status())
185            ///     .options(["draft", "published"])
186            ///     .default("draft");
187            /// Field::text(Post::fields().views()).default(0_i64).disabled();
188            /// Field::choice_input("channels")
189            ///     .options(["email", "sms"])
190            ///     .multiple()
191            ///     .default(["email"]);
192            /// ```
193            ///
194            /// - The control names its help text in `aria-describedby`, beside its error.
195            /// - A disabled control renders `disabled`, so the browser posts nothing for it, and
196            ///   the panel drops a key posted anyway, with a file field's carried upload. An edit
197            ///   keeps the stored value. A create stores the field's default, else its blank
198            ///   answer: `.disabled()` on a field with neither refuses to mount, since no create
199            ///   could fill it.
200            /// - `.disabled_on_edit()` leaves the create form's control enabled: the create sets
201            ///   the value, and no edit changes it.
202            /// - A condition watching a disabled field reads what the browser shows: its default on
203            ///   a create, its stored value on an edit, never what the submission posts.
204            /// - A default seeds the create form and an action's input form; an edit form shows the
205            ///   stored value. The blank answer is different: it is what an empty submission
206            ///   stores. Mounting refuses a default the control never posts: one a typed field does
207            ///   not parse, or not one of a choice's options.
208            /// - A repeater's row controls take neither `disabled` nor `default`: mounting refuses
209            ///   both.
210            pub fn default(mut self, value: impl IntoFormValue) -> Self {
211                self.0.default = Some(value.into_form_value());
212                self
213            }
214        }
215    )*};
216}
217
218default_modifier!(TextField, ChoiceField, CustomField);
219#[cfg(feature = "rich-text")]
220default_modifier!(RichTextField);
221
222/// A text field: [`Field::text`], or a record form's text control (`F` is the form).
223pub struct TextField<F = ()>(pub(super) Field, PhantomData<fn() -> F>);
224
225/// A choice field: [`Field::choice`], or a record form's choice control (`F` is the form).
226pub struct ChoiceField<F = ()>(pub(super) Field, PhantomData<fn() -> F>);
227
228/// A file field: [`Field::file`], or a record form's file control (`F` is the form).
229pub struct FileField<F = ()>(pub(super) Field, PhantomData<fn() -> F>);
230
231/// A repeater: [`Field::repeater`], or a record form's `#[form(repeat)]` control (`F` is the
232/// form).
233///
234/// - Each row posts its item's fields under its own prefix (`steps.0.minutes`), and the repeater's
235///   own key lists the rows in the order they show. A refused row renders its error under its own
236///   control, and the rows come back in the order the user left them.
237/// - A repeater takes at most `MAX_REPEATER_ROWS` (100) rows; a submission listing more is refused.
238/// - No rows is the repeater's blank answer, so a repeater is never required. An edit that does not
239///   post the repeater, such as one hidden by a condition, keeps the stored rows; one that posts no
240///   rows stores an empty list.
241/// - Adding, removing and moving rows needs JavaScript. Without it the stored rows still edit.
242/// - An action's input can place a repeater too, with a hand-written `FormInput` whose `parse`
243///   reads the rows with `parse_items`.
244/// - On the detail page, `RepeaterColumn` shows each item's fields under their labels. The model's
245///   default detail page includes one; its table does not.
246/// - Not supported inside an item: an embedded value, another repeater, a relationship, a file
247///   field and a condition.
248pub struct RepeaterField<F = ()>(pub(super) Field, PhantomData<fn() -> F>);
249
250/// A field an app's [`Control`] renders: [`Field::custom`] and [`Field::toggle`], or a record
251/// form's toggle (`F` is the form).
252pub struct CustomField<F = ()>(pub(super) Field, PhantomData<fn() -> F>);
253
254impl<F> CustomField<F> {
255    /// Renders a `bool` field as a switch rather than a checkbox: the same toggle, posting `true`
256    /// or `false`, drawn as an on/off track. A condition follows it as it follows a toggle.
257    ///
258    /// ```rust
259    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
260    /// # struct Post { #[key] #[auto] id: uuid::Uuid, featured: bool }
261    /// # use tablo_core::{Field, lens};
262    /// Field::toggle(lens!(Post.featured)).switch();
263    /// ```
264    pub fn switch(mut self) -> Self {
265        self.0.control = ControlKind::Custom(Arc::new(super::Switch));
266        self.0.checkbox = true;
267        self
268    }
269
270    /// Whether the control is the built-in checkbox, which a condition follows through `checked`.
271    pub(crate) fn is_checkbox(&self) -> bool {
272        self.0.is_checkbox()
273    }
274
275    /// Refuse a submission that leaves the checkbox unchecked, and render it `required`: the box
276    /// accepting the terms an action's input asks for. Only the built-in checkbox of
277    /// [`Field::toggle`] and [`Field::toggle_input`] takes it, drawn as a box or a
278    /// [`switch`](Self::switch); another control fails the mount, as
279    /// does a [`disabled`](Self::disabled) one with no `true` default. A hidden field is not
280    /// checked, nor a value the record already stores. A request a
281    /// [`FieldAccess`](crate::FieldAccess) `view` rule hides the field from may not create unless
282    /// it has a `true` default.
283    ///
284    /// ```rust
285    /// # use tablo_core::Field;
286    /// Field::toggle_input("terms")
287    ///     .label("I accept the terms")
288    ///     .accepted();
289    /// ```
290    pub fn accepted(mut self) -> Self {
291        self.0.accepted = true;
292        self
293    }
294}
295
296common_modifiers!(TextField);
297#[cfg(feature = "rich-text")]
298common_modifiers!(RichTextField);
299common_modifiers!(ChoiceField);
300common_modifiers!(FileField);
301common_modifiers!(CustomField);
302base_modifiers!(RepeaterField);
303
304/// `choice`, on every builder but the choice's own.
305macro_rules! to_choice {
306    ($($builder:ident),*) => {$(
307        impl<F> $builder<F> {
308            /// Renders the field as a choice instead, as [`Field::choice`] does, ready for
309            /// [`options`](ChoiceField::options) or [`relationship`](ChoiceField::relationship).
310            /// The replaced control's own modifiers are dropped with it.
311            pub fn choice(mut self) -> ChoiceField<F> {
312                self.0.control = ControlKind::Choice(ChoiceControl::default());
313                self.0.checkbox = false;
314                ChoiceField(self.0, PhantomData)
315            }
316        }
317    )*};
318}
319
320to_choice!(TextField, FileField, CustomField);
321
322impl<F> TextField<F> {
323    /// Renders the field as a [rich text](Field::rich_text) editor instead, storing HTML. The
324    /// field's type must be `String` or `Option<String>`, else mounting the panel fails, and the
325    /// text control's own modifiers are dropped.
326    ///
327    /// ```rust
328    /// # #[derive(Debug, Clone, toasty::Model)]
329    /// # struct Post { #[key] #[auto] id: uuid::Uuid, body: String }
330    /// # #[derive(tablo_core::RecordForm)]
331    /// # #[form(model = Post)]
332    /// # struct PostForm { body: String }
333    /// use tablo_core::Tool;
334    ///
335    /// let c = PostForm::controls();
336    /// c.body
337    ///     .rich_text()
338    ///     .toolbar(&[&[Tool::Bold, Tool::Italic], &[Tool::BulletList]]);
339    /// ```
340    #[cfg(feature = "rich-text")]
341    pub fn rich_text(mut self) -> RichTextField<F> {
342        let input = self.text().input_type();
343        self.0.control = ControlKind::RichText(RichTextControl {
344            non_text: (input != "text").then_some(input),
345            ..RichTextControl::default()
346        });
347        RichTextField(self.0, PhantomData)
348    }
349
350    fn text(&mut self) -> &mut TextControl {
351        match &mut self.0.control {
352            ControlKind::Text(text) => text,
353            _ => unreachable!("a TextField holds a text control"),
354        }
355    }
356
357    /// Validate the value as an email address, and render `type="email"`: the domain needs two
358    /// labels (`a@b` is refused), a display name (`Ada <ada@example.com>`) is refused, and the
359    /// address is capped at 254 bytes.
360    pub fn email(mut self) -> Self {
361        self.text().kind = Kind::Email;
362        self
363    }
364
365    /// Validate the value as an absolute `http` or `https` URL naming a host, and render
366    /// `type="url"`. The browser's own check takes any scheme, so the server refuses the others,
367    /// such as `javascript:`, which a link to the value would run.
368    pub fn url(mut self) -> Self {
369        self.text().kind = Kind::Url;
370        self
371    }
372
373    /// Render `type="tel"`, which brings up a phone keypad. No format is checked, since numbers
374    /// vary by country: add a [`pattern`](Self::pattern) to require one.
375    pub fn tel(mut self) -> Self {
376        self.text().kind = Kind::Tel;
377        self
378    }
379
380    /// Render `type="password"`: the browser masks the value, a button beside it shows what was
381    /// typed, and a refused form renders the control empty rather than echoing the password back
382    /// into the page. It renders the one-line
383    /// input even when [`multiline`](Self::multiline).
384    pub fn password(mut self) -> Self {
385        self.text().password = true;
386        self
387    }
388
389    /// A [`password`](Self::password) the user signs in with today, as a profile's current
390    /// password: the control asks a password manager to fill the stored one.
391    pub(crate) fn current_password(mut self) -> Self {
392        let text = self.text();
393        text.password = true;
394        text.current_password = true;
395        self
396    }
397
398    /// Probes a unique index before the write, and reports a taken value inline. A text field over
399    /// a column with a unique index probes without it; mounting refuses it on a column no unique
400    /// index covers, and on a non-nullable column whose record-form field has a blank answer,
401    /// since every empty submission would store one value.
402    ///
403    /// The probe runs before the write, so two concurrent submissions can both pass it; the index
404    /// stays the final check. When a write fails on a database error, the form probes again once
405    /// the transaction is rolled back, so the losing submission re-renders with the same inline
406    /// error, and a failure the probe does not explain answers 500. That needs the database to
407    /// refuse the losing write once the winning one commits, as PostgreSQL and MySQL do; SQLite
408    /// can refuse it as busy before then, and Turso's concurrent transactions conflict at commit,
409    /// so either can still answer 500. The probe reads through the resource's scoped query, so an
410    /// index broader than the scope, such as one wider than the tenancy, also answers 500.
411    pub fn unique(mut self) -> Self {
412        self.text().unique = true;
413        self
414    }
415
416    /// The control's placeholder text.
417    pub fn placeholder(mut self, placeholder: impl Into<crate::Text>) -> Self {
418        self.text().placeholder = Some(placeholder.into());
419        self
420    }
421
422    /// Text shown before the one-line input, joined to it, such as a currency: `.prefix("$")`. It
423    /// is not part of the value, and a [`multiline`](Self::multiline) textarea renders none.
424    pub fn prefix(mut self, text: impl Into<crate::Text>) -> Self {
425        self.text().prefix = Some(text.into());
426        self
427    }
428
429    /// Text shown after the one-line input, joined to it, such as a unit: `.suffix("kg")`. Like a
430    /// [`prefix`](Self::prefix), it is not part of the value.
431    pub fn suffix(mut self, text: impl Into<crate::Text>) -> Self {
432        self.text().suffix = Some(text.into());
433        self
434    }
435
436    /// Values the one-line input suggests as the user types, through a `<datalist>`. The user may
437    /// still enter any other value: use a [choice](Self::choice) to restrict them. A
438    /// [`password`](Self::password) suggests none.
439    ///
440    /// ```rust
441    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
442    /// # struct Product { #[key] #[auto] id: uuid::Uuid, weight: i64, brand: String }
443    /// # use tablo_core::{Field, lens};
444    /// Field::text(lens!(Product.weight)).suffix("kg");
445    /// Field::text(lens!(Product.brand)).suggestions(["Acme", "Globex", "Initech"]);
446    /// ```
447    pub fn suggestions(mut self, values: impl IntoIterator<Item = impl Into<String>>) -> Self {
448        self.text().suggestions = values.into_iter().map(Into::into).collect();
449        self
450    }
451
452    /// Render a `<textarea>` of `rows` lines rather than a one-line input,
453    /// for a column holding prose.
454    pub fn multiline(mut self, rows: u32) -> Self {
455        self.text().rows = Some(rows);
456        self
457    }
458
459    /// Refuse a value of fewer than `chars` characters, and render `minlength`.
460    ///
461    /// Like every rule here, the browser checks it before it submits, and the server checks it
462    /// again on the trimmed value once the field's type parses it. A blank value is the field's
463    /// blank answer, which no rule refuses, and a value the record already stores is not checked
464    /// again. A length counts characters, where a browser counts UTF-16 units.
465    pub fn min_length(mut self, chars: usize) -> Self {
466        self.text().rules.min_length = Some(chars);
467        self
468    }
469
470    /// Refuse a value of more than `chars` characters, and render `maxlength`, which stops the
471    /// browser's input there.
472    pub fn max_length(mut self, chars: usize) -> Self {
473        self.text().rules.max_length = Some(chars);
474        self
475    }
476
477    /// Refuse a value below `bound`, and render `min`.
478    ///
479    /// The bound is a value of the field's type, which orders: a number, or a `jiff` date, time,
480    /// date and time, or timestamp. `.min(0)` on an `i64` field, `.min(date(2024, 1, 1))` on a
481    /// `jiff::civil::Date` one; a bound the type does not parse or order fails the mount. A time's
482    /// control edits it to the minute, so give a time bound in whole minutes.
483    pub fn min(mut self, bound: impl std::fmt::Display) -> Self {
484        self.text().rules.min = Some(bound.to_string());
485        self
486    }
487
488    /// Refuse a value above `bound`, and render `max`; [`min`](Self::min) says which bounds a
489    /// field takes.
490    pub fn max(mut self, bound: impl std::fmt::Display) -> Self {
491        self.text().rules.max = Some(bound.to_string());
492        self
493    }
494
495    /// Refuse a value the regular expression `pattern` does not match whole, and render it as
496    /// the `pattern` attribute.
497    ///
498    /// The server matches it with the `regex` crate and the browser as a JavaScript expression in
499    /// its `v` mode, both against the whole value, so write it in the syntax they share: classes,
500    /// groups, alternation and repetition. Spell digits `[0-9]`, since the server's `\d` matches
501    /// other scripts' digits too; escape a `-` inside a class, which the browser otherwise refuses
502    /// along with the whole pattern; and leave out lookaround, backreferences and inline flags.
503    /// One the `regex` crate does not compile fails the mount. A `.multiline` textarea renders no
504    /// `pattern`, so only the server checks it there.
505    ///
506    /// ```rust
507    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
508    /// # struct Product { #[key] #[auto] id: uuid::Uuid, sku: String }
509    /// # use tablo_core::{Field, lens};
510    /// Field::text(lens!(Product.sku)).pattern("[A-Z]{3}-[0-9]{4}");
511    /// ```
512    pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
513        self.text().rules.pattern = Some(Pattern::new(pattern.into()));
514        self
515    }
516}
517
518impl<F> RepeaterField<F> {
519    fn repeater(&mut self) -> &mut RepeaterControl {
520        match &mut self.0.control {
521            ControlKind::Repeater(repeater) => repeater,
522            _ => unreachable!("a RepeaterField holds a repeater control"),
523        }
524    }
525
526    /// The add button's label, over "Add item".
527    pub fn add_label(mut self, label: impl Into<crate::Text>) -> Self {
528        self.repeater().add_label = Some(label.into());
529        self
530    }
531
532    /// Refuse a submission of fewer than `count` rows, and disable each row's Remove button while
533    /// the list holds no more. A minimum above `max_items` fails the mount.
534    pub fn min_items(mut self, count: usize) -> Self {
535        self.repeater().bounds.min = Some(count);
536        self
537    }
538
539    /// Refuse a submission of more than `count` rows, and disable the add button while the list
540    /// holds that many. A count above [`MAX_REPEATER_ROWS`](crate::MAX_REPEATER_ROWS), which
541    /// every repeater keeps to, fails the mount.
542    pub fn max_items(mut self, count: usize) -> Self {
543        self.repeater().bounds.max = Some(count);
544        self
545    }
546}
547
548mod into;
549
550/// The crate's tests read a builder's field as the schema will hold it.
551#[cfg(test)]
552mod test_deref {
553    use super::{ChoiceField, CustomField, Field, FileField, RepeaterField, TextField};
554
555    macro_rules! deref_field {
556        ($($builder:ident),*) => {$(
557            impl<F> std::ops::Deref for $builder<F> {
558                type Target = Field;
559                fn deref(&self) -> &Field {
560                    &self.0
561                }
562            }
563        )*};
564    }
565
566    deref_field!(
567        TextField,
568        ChoiceField,
569        FileField,
570        CustomField,
571        RepeaterField
572    );
573}