Skip to main content

tablo_core/schema/fields/
mod.rs

1//! One [`Field`] type whose control is text, choice, file, or an app's own [`Control`], built from
2//! a path binding a column or an embedded leaf.
3
4mod builders;
5mod choice;
6mod custom;
7mod file;
8mod text;
9
10use std::sync::Arc;
11
12pub use builders::{ChoiceField, CustomField, FileField, IntoOptions, TextField};
13pub(crate) use choice::{ChoiceControl, option_view};
14pub use custom::{Control, ControlInput, Toggle};
15use tablo_ui::{
16    field as ui_field, field_content as ui_field_content, field_error as ui_field_error,
17    field_label as ui_field_label, field_title as ui_field_title,
18};
19pub(crate) use text::TextControl;
20use toasty::stmt::Path;
21use topcoat::{Result, context::Cx, view::*};
22
23use super::{
24    lenses::{Binding, FieldResolver},
25    tree::Mode,
26    validation::is_email,
27};
28use crate::{
29    form::{FieldError, FormScalar},
30    naming::capitalize,
31};
32
33/// One form field binding a lens to the control editing it, offering only that control's modifiers
34/// so a modifier on the wrong control does not compile.
35///
36/// ```rust
37/// # #[derive(Debug, Clone, toasty::Model)]
38/// # struct User {
39/// #     #[key] #[auto] id: uuid::Uuid,
40/// #     name: String,
41/// #     email: String,
42/// #     age: i64,
43/// # }
44/// # #[derive(Debug, Clone, toasty::Model)]
45/// # struct Post {
46/// #     #[key] #[auto] id: uuid::Uuid,
47/// #     body: String,
48/// #     status: String,
49/// #     author_id: uuid::Uuid,
50/// #     featured: bool,
51/// # }
52/// # #[derive(Debug, Clone, toasty::Model)]
53/// # struct Doc { #[key] #[auto] id: uuid::Uuid, path: String }
54/// # #[derive(Debug, Clone, toasty::Model)]
55/// # struct Author { #[key] #[auto] id: uuid::Uuid, name: String }
56/// # #[derive(Debug, Clone, Copy, PartialEq, Eq, tablo_core::Options)]
57/// # enum Status { Draft, Published }
58/// # struct AuthorResource;
59/// # impl tablo_core::schema::OptionSource for AuthorResource {
60/// #     type Model = Author;
61/// #     fn scoped_query(_cx: &topcoat::context::Cx)
62/// #         -> topcoat::Result<toasty::stmt::Query<toasty::stmt::List<Author>>>
63/// #     {
64/// #         Ok(toasty::stmt::Query::all())
65/// #     }
66/// # }
67/// # use tablo_core::{Field, Options};
68/// Field::text(User::fields().name()).placeholder("Ada Lovelace"); // TextField
69/// Field::text(User::fields().email()).email().unique();
70/// Field::text(User::fields().age()); // typed: an `i64` column
71/// Field::text(Post::fields().body()).multiline(6);
72/// Field::choice(Post::fields().status()).options(Status::options()); // ChoiceField
73/// Field::choice(Post::fields().author_id())
74///     .relationship::<AuthorResource>(|a: &Author| a.name.clone());
75/// Field::file(Doc::fields().path()); // FileField
76/// Field::toggle(Post::fields().featured()); // CustomField
77/// ```
78///
79/// ```compile_fail
80/// # #[derive(Debug, Clone, toasty::Model)]
81/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String }
82/// # fn main() {
83/// tablo_core::Field::text(User::fields().name()).options(["admin", "member"]);
84/// # }
85/// ```
86pub struct Field {
87    binding: Binding,
88    /// The declared label, over the binding's.
89    label: Option<String>,
90    /// Whether the control renders as required: the panel sets it from the record form, and an
91    /// embedded value's derive from its own fields.
92    required: bool,
93    control: ControlKind,
94}
95
96/// The control a [`Field`] renders, with what only that control declares.
97pub(crate) enum ControlKind {
98    /// A one-line `<input>`, or a `<textarea>` when `rows` is set.
99    Text(TextControl),
100    /// A `<select>` over static options or a relationship's rows.
101    Choice(ChoiceControl),
102    /// A file input storing the uploaded file's path.
103    File,
104    /// An app's control, or the built-in [`Toggle`].
105    Custom(Arc<dyn Control>),
106}
107
108impl std::fmt::Debug for Field {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        let control = match &self.control {
111            ControlKind::Text(_) => "text",
112            ControlKind::Choice(_) => "choice",
113            ControlKind::File => "file",
114            ControlKind::Custom(_) => "custom",
115        };
116        f.debug_struct("Field")
117            .field("name", &self.name())
118            .field("label", &self.label_str())
119            .field("required", &self.required)
120            .field("control", &control)
121            .finish()
122    }
123}
124
125impl Field {
126    fn bound(binding: Binding, control: ControlKind) -> Self {
127        Self {
128            binding,
129            label: None,
130            required: false,
131            control,
132        }
133    }
134
135    /// Binds any [`FormScalar`] column, storing the type's own spelling and probing uniqueness
136    /// through the lens.
137    pub fn text<M, T>(lens: impl Into<Path<M, T>>) -> TextField
138    where
139        M: toasty::schema::Model,
140        T: FormScalar + toasty::stmt::IntoExpr<T> + 'static,
141    {
142        let path: Path<M, T> = lens.into();
143        let binding = Binding::of(&path);
144        let control = TextControl::new::<M, T>(path, binding.unique());
145        TextField(Self::bound(binding, ControlKind::Text(control)))
146    }
147
148    /// The text field `#[derive(EmbeddedForm)]` renders for a leaf.
149    #[doc(hidden)]
150    pub fn embedded_leaf<M, T>(path: Path<M, T>) -> TextField
151    where
152        M: toasty::schema::Model,
153        T: FormScalar,
154    {
155        TextField(Self::bound(
156            Binding::of(&path),
157            ControlKind::Text(TextControl::leaf::<T>()),
158        ))
159    }
160
161    /// A choice field over any column, with options from [`options`](ChoiceField::options) or
162    /// [`relationship`](ChoiceField::relationship).
163    pub fn choice<M, T>(lens: impl Into<Path<M, T>>) -> ChoiceField
164    where
165        M: toasty::schema::Model,
166    {
167        ChoiceField(Self::bound(
168            Binding::of::<M, T>(&lens.into()),
169            ControlKind::Choice(ChoiceControl::default()),
170        ))
171    }
172
173    /// A file field over a `String` column holding the uploaded path, rendering no `value`
174    /// attribute.
175    pub fn file<M>(lens: impl Into<Path<M, String>>) -> FileField
176    where
177        M: toasty::schema::Model,
178    {
179        FileField(Self::bound(
180            Binding::of::<M, String>(&lens.into()),
181            ControlKind::File,
182        ))
183    }
184
185    /// A checkbox over a `bool` column that submits `false` when unchecked.
186    pub fn toggle<M>(lens: impl Into<Path<M, bool>>) -> CustomField
187    where
188        M: toasty::schema::Model,
189    {
190        Self::custom(lens, Toggle)
191    }
192
193    /// A field over any [`FormScalar`] column, rendered by an app's [`Control`].
194    pub fn custom<M, T>(lens: impl Into<Path<M, T>>, control: impl Control + 'static) -> CustomField
195    where
196        M: toasty::schema::Model,
197        T: FormScalar,
198    {
199        CustomField(Self::bound(
200            Binding::of::<M, T>(&lens.into()),
201            ControlKind::Custom(Arc::new(control)),
202        ))
203    }
204
205    /// The variant control of an embedded enum, never required since an empty submit reads the
206    /// variant from the payload.
207    pub(crate) fn discriminant(name: String, variants: Vec<(String, String)>) -> Self {
208        Self {
209            binding: Binding::named(name.clone(), capitalize(&name)),
210            label: None,
211            required: false,
212            control: ControlKind::Choice(ChoiceControl {
213                discriminant: true,
214                options: variants,
215                ..ChoiceControl::default()
216            }),
217        }
218    }
219
220    /// Bind an embedded path through `resolver`'s app schema.
221    pub(crate) fn bind(&self, resolver: &FieldResolver) {
222        self.binding.bind(resolver);
223    }
224
225    pub(crate) fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
226        self.binding.misdeclared()
227    }
228
229    /// The key the field posts: the storage column its path names.
230    pub fn name(&self) -> &str {
231        self.binding.name()
232    }
233
234    pub(crate) fn label_str(&self) -> &str {
235        self.label.as_deref().unwrap_or(self.binding.label())
236    }
237
238    pub(crate) fn as_choice(&self) -> Option<&ChoiceControl> {
239        match &self.control {
240            ControlKind::Choice(choice) => Some(choice),
241            _ => None,
242        }
243    }
244
245    /// Probes uniqueness with the submission parsed through the lens.
246    pub(crate) fn eq_filter(&self, value: &str) -> Option<toasty::stmt::Expr<bool>> {
247        match &self.control {
248            ControlKind::Text(text) => text.eq_filter(value),
249            _ => None,
250        }
251    }
252
253    pub(crate) fn is_file(&self) -> bool {
254        matches!(self.control, ControlKind::File)
255    }
256
257    pub(crate) fn is_unique(&self) -> bool {
258        matches!(&self.control, ControlKind::Text(text) if text.unique)
259    }
260
261    /// Whether the control renders as required.
262    pub(crate) fn is_required(&self) -> bool {
263        self.required
264    }
265
266    /// Renders the control required when its record-form field has no blank answer.
267    pub(crate) fn set_required(&mut self, required: bool) {
268        self.required = required;
269    }
270
271    /// Whether the bound column stores NULL, which a unique index admits any number of times.
272    pub(crate) fn is_nullable(&self) -> bool {
273        self.binding.nullable()
274    }
275
276    /// Refuses a non-empty submission the control's own rule refuses: an email field's address.
277    pub(crate) fn check(&self, value: &str) -> Option<FieldError> {
278        let value = value.trim();
279        match &self.control {
280            ControlKind::Text(text) if text.email && !value.is_empty() && !is_email(value) => {
281                Some(FieldError::invalid(
282                    self.name(),
283                    format!("{} must be a valid email", self.label_str()),
284                ))
285            }
286            _ => None,
287        }
288    }
289
290    /// Whether two submissions spell the same stored value, as the unique check compares them.
291    pub(crate) fn same_value(&self, a: &str, b: &str) -> bool {
292        match &self.control {
293            ControlKind::Text(text) => text.same_value(a, b),
294            _ => a.trim() == b.trim(),
295        }
296    }
297
298    /// Whether a submitted choice matches its options.
299    pub(crate) async fn validate_exists(&self, cx: &Cx, value: &str) -> Vec<String> {
300        match &self.control {
301            ControlKind::Choice(choice) => {
302                choice.validate_exists(cx, self.label_str(), value).await
303            }
304            _ => Vec::new(),
305        }
306    }
307
308    /// Re-checks a relationship choice in the write's transaction.
309    pub(crate) async fn recheck(
310        &self,
311        cx: &Cx,
312        value: &str,
313        ex: &mut dyn toasty::Executor,
314    ) -> Vec<String> {
315        match &self.control {
316            ControlKind::Choice(choice) => choice.recheck(cx, self.label_str(), value, ex).await,
317            _ => Vec::new(),
318        }
319    }
320
321    /// Renders the field's control or its stored value.
322    pub(crate) async fn render<'a>(
323        &self,
324        cx: &'a Cx,
325        value: Option<&str>,
326        error: Option<&str>,
327        mode: Mode,
328    ) -> Result<BoxView<'a>> {
329        if mode == Mode::View && value.is_none() {
330            debug_assert!(
331                false,
332                "view field `{}` has no value: neither `view_values` nor the record form's \
333                 `hydrate` supplies its key",
334                self.name()
335            );
336            return render_value(cx, self.label_str(), Some("(missing)"), ValueKind::Prose);
337        }
338        match &self.control {
339            ControlKind::Text(text) => self.render_text(text, cx, value, error, mode),
340            ControlKind::Choice(choice) => {
341                Box::pin(self.render_choice(choice, cx, value, error, mode)).await
342            }
343            ControlKind::File => self.render_file(cx, value, error, mode),
344            ControlKind::Custom(control) => {
345                self.render_custom(control.as_ref(), cx, value, error, mode)
346            }
347        }
348    }
349
350    /// Renders an app [`Control`] in its shared chrome.
351    fn render_custom<'a>(
352        &self,
353        control: &dyn Control,
354        cx: &'a Cx,
355        value: Option<&str>,
356        error: Option<&str>,
357        mode: Mode,
358    ) -> Result<BoxView<'a>> {
359        if mode == Mode::View {
360            let shown = control.display(cx, value.unwrap_or_default());
361            return render_value_view(cx, self.label_str(), shown);
362        }
363        let required = self.is_required();
364        let chrome = FieldChrome::new(self.name(), error, None);
365        let input = ControlInput::new(
366            self.name(),
367            value,
368            required,
369            chrome.aria_invalid() == "true",
370            chrome.described_by(),
371        );
372        let rendered = control.render(cx, input);
373        render_field(
374            cx,
375            &chrome,
376            self.label_str(),
377            required,
378            attributes! {},
379            rendered,
380        )
381    }
382}
383
384/// How a read-only value is presented.
385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
386pub(crate) enum ValueKind {
387    /// Wrapping text: a title, a body, an address.
388    Prose,
389    /// A stored path — no spaces to break at.
390    Machine,
391}
392
393/// The read-only half of a field: the label with the record's stored value under it.
394fn render_value<'a>(
395    cx: &'a Cx,
396    label: &str,
397    value: Option<&str>,
398    kind: ValueKind,
399) -> Result<BoxView<'a>> {
400    let text = value.unwrap_or_default().to_string();
401    let value_class = match kind {
402        ValueKind::Prose => "text-sm text-foreground wrap-anywhere whitespace-pre-wrap",
403        ValueKind::Machine => "text-sm text-foreground font-mono break-all whitespace-pre-wrap",
404    };
405    let value = view! { cx => <div class=(value_class)>(text)</div> }.boxed();
406    render_value_view(cx, label, value)
407}
408
409/// The field chrome around a read-only value that is not a plain string.
410fn render_value_view<'a>(cx: &'a Cx, label: &str, value: BoxView<'a>) -> Result<BoxView<'a>> {
411    let label = label.to_string();
412    Ok(view! {
413        cx =>
414        ui_field(
415            attrs: attributes! { class="ac-field" },
416            ui_field_content(
417                ui_field_title((label))
418                (value)
419            )
420        )
421    }
422    .boxed())
423}
424
425/// The validation state a form control renders.
426pub(crate) struct FieldChrome {
427    name: String,
428    error_id: String,
429    error_text: String,
430    has_error: bool,
431}
432
433impl FieldChrome {
434    pub(crate) fn new(name: &str, error: Option<&str>, fallback: Option<String>) -> Self {
435        let has_error = error.is_some() || fallback.is_some();
436        let error_text = match error {
437            Some(message) if !message.is_empty() => message.to_string(),
438            _ => fallback.unwrap_or_default(),
439        };
440        Self {
441            name: name.to_string(),
442            error_id: format!("{name}-error"),
443            error_text,
444            has_error,
445        }
446    }
447
448    pub(crate) fn aria_invalid(&self) -> &'static str {
449        if self.has_error { "true" } else { "false" }
450    }
451
452    pub(crate) fn described_by(&self) -> Option<String> {
453        self.has_error.then(|| self.error_id.clone())
454    }
455}
456
457/// The chrome every form control renders: wrapper, label, control, and error slot.
458pub(crate) fn render_field<'a>(
459    cx: &'a Cx,
460    chrome: &FieldChrome,
461    label: &str,
462    required: bool,
463    attributes: Attributes,
464    control: BoxView<'a>,
465) -> Result<BoxView<'a>> {
466    let name = chrome.name.clone();
467    let label_text = label.to_string();
468    let has_error = chrome.has_error;
469    let error_id = chrome.error_id.clone();
470    let error_text = chrome.error_text.clone();
471    let field_class = if has_error {
472        "ac-field ac-field--error"
473    } else {
474        "ac-field"
475    };
476    Ok(view! {
477        cx =>
478        ui_field(
479            attrs: attributes! {
480                class=(field_class)
481                data-invalid=(has_error.then_some("true"))
482                (attributes)
483            },
484            ui_field_label(
485                attrs: attributes! { for=(name) },
486                (label_text)
487                if required {
488                    <span class="text-destructive" aria-hidden="true">"*"</span>
489                }
490            )
491            (control)
492            if has_error {
493                ui_field_error(
494                    attrs: attributes! { id=(error_id) class="ac-error" aria-live="polite" },
495                    (error_text)
496                )
497            }
498        )
499    }
500    .boxed())
501}
502
503#[cfg(test)]
504mod test_support {
505    pub(super) use crate::test_support::{DummyUser, cx};
506
507    /// The opening tag carrying `needle` (attributes render unordered, topcoat#122).
508    pub(super) fn tag_with<'h>(html: &'h str, needle: &str) -> &'h str {
509        let at = html
510            .find(needle)
511            .unwrap_or_else(|| panic!("no {needle} in {html}"));
512        opening_tag_at(html, html[..at].rfind('<').expect("its opening tag"))
513    }
514
515    /// Slices the opening tag at `start`, honouring quoting (topcoat#122).
516    pub(super) fn opening_tag_at(html: &str, start: usize) -> &str {
517        let mut quoted = false;
518        for (offset, byte) in html.as_bytes()[start..].iter().enumerate() {
519            match byte {
520                b'"' => quoted = !quoted,
521                b'>' if !quoted => return &html[start..start + offset],
522                _ => {}
523            }
524        }
525        panic!("unterminated tag at byte {start} in {html}");
526    }
527
528    /// The sorted attributes of the tag carrying `needle` (unordered, topcoat#122).
529    pub(super) fn attributes_of(html: &str, needle: &str) -> Vec<String> {
530        let mut quoted = false;
531        let mut attrs: Vec<String> = Vec::new();
532        let mut current = String::new();
533        for ch in tag_with(html, needle).chars() {
534            match ch {
535                '"' => {
536                    quoted = !quoted;
537                    current.push(ch);
538                }
539                ch if ch.is_whitespace() && !quoted => {
540                    if !current.is_empty() {
541                        attrs.push(std::mem::take(&mut current));
542                    }
543                }
544                ch => current.push(ch),
545            }
546        }
547        if !current.is_empty() {
548            attrs.push(current);
549        }
550        attrs.remove(0); // the tag name
551        attrs.sort();
552        attrs
553    }
554}
555
556#[cfg(test)]
557mod tests;