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