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}