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}