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