Skip to main content

tablo_core/schema/
fields.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;
6pub(crate) mod custom;
7mod file;
8mod text;
9
10use std::sync::Arc;
11
12pub use builders::{
13    ChoiceField, CustomField, FileField, IntoFormValue, IntoOptions, RepeaterField, TextField,
14};
15pub(crate) use choice::{ChoiceControl, Relationship, option_view};
16pub use custom::Toggle;
17pub(crate) use custom::{Control, ControlInput};
18pub(crate) use file::stored_upload;
19use tablo_ui::{
20    field as ui_field, field_content as ui_field_content,
21    field_description as ui_field_description, field_error as ui_field_error,
22    field_label as ui_field_label, field_title as ui_field_title,
23};
24pub(crate) use text::TextControl;
25use toasty::stmt::{List, Path};
26use topcoat::{Result, context::Cx, view::*};
27
28use super::{
29    condition::Condition,
30    lenses::{Binding, FieldResolver},
31    repeater::{RepeaterControl, RepeaterItem},
32    validation::is_email,
33};
34use crate::{
35    form::{FieldError, FieldErrorKind, FormScalar, FormValue},
36    naming::capitalize,
37};
38
39/// One form field binding a lens to the control editing it, offering only that control's modifiers
40/// so a modifier on the wrong control does not compile.
41///
42/// ```rust
43/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
44/// # struct User {
45/// #     #[key] #[auto] id: uuid::Uuid,
46/// #     name: String,
47/// #     email: String,
48/// #     age: i64,
49/// # }
50/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
51/// # struct Post {
52/// #     #[key] #[auto] id: uuid::Uuid,
53/// #     body: String,
54/// #     status: String,
55/// #     author_id: uuid::Uuid,
56/// #     featured: bool,
57/// # }
58/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
59/// # struct Doc { #[key] #[auto] id: uuid::Uuid, path: String }
60/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
61/// # struct Author { #[key] #[auto] id: uuid::Uuid, name: String }
62/// # #[derive(Debug, Clone, Copy, PartialEq, Eq, tablo_core::Options)]
63/// # enum Status { Draft, Published }
64/// # struct AuthorResource;
65/// # impl tablo_core::extend::OptionSource for AuthorResource {
66/// #     type Model = Author;
67/// #     fn scoped_query(_cx: &topcoat::context::Cx)
68/// #         -> topcoat::Result<toasty::stmt::Query<toasty::stmt::List<Author>>>
69/// #     {
70/// #         Ok(toasty::stmt::Query::all())
71/// #     }
72/// #     fn label(_cx: &topcoat::context::Cx, author: &Author) -> String {
73/// #         author.name.clone()
74/// #     }
75/// # }
76/// # use tablo_core::{Field, Options, lens};
77/// Field::text(lens!(User.name)).placeholder("Ada Lovelace"); // TextField
78/// Field::text(lens!(User.email)).email().unique();
79/// Field::text(lens!(User.age)); // typed: an `i64` column
80/// Field::text(lens!(Post.body)).multiline(6);
81/// Field::choice(lens!(Post.status)).options(Status::options()); // ChoiceField
82/// Field::choice(lens!(Post.author_id)).relationship::<AuthorResource>();
83/// Field::file(lens!(Doc.path)); // FileField
84/// Field::toggle(lens!(Post.featured)); // CustomField
85/// ```
86///
87/// Options belong to a choice: a text field has no `options` method.
88pub struct Field {
89    binding: Binding,
90    /// The declared label, over the binding's.
91    label: Option<String>,
92    /// Whether the control renders as required: the panel sets it from the record form, and an
93    /// embedded value's derive from its own fields.
94    required: bool,
95    control: ControlKind,
96    /// The condition showing the field, set by `visible_when`.
97    condition: Option<Condition>,
98    /// Whether the control is a checkbox, which a condition follows through `checked`.
99    checkbox: bool,
100    /// The help text under the control, set by `help`.
101    help: Option<String>,
102    /// When the control renders disabled and the server ignores what it posts.
103    disabled: Disabled,
104    /// The create form's initial value, as the control posts it, set by `default`.
105    default: Option<FormValue>,
106}
107
108/// When a [`Field`] renders disabled, so the browser posts nothing for it and the server drops
109/// a key posted anyway.
110#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
111pub(crate) enum Disabled {
112    #[default]
113    Never,
114    Always,
115    /// On the edit form only: the create form takes the value, which no edit then changes.
116    OnEdit,
117}
118
119/// The control a [`Field`] renders, with what only that control declares.
120pub(crate) enum ControlKind {
121    /// A one-line `<input>`, or a `<textarea>` when `rows` is set.
122    Text(TextControl),
123    /// A `<select>` over static options or a relationship's rows.
124    Choice(ChoiceControl),
125    /// A file input storing the uploaded file's path.
126    File,
127    /// An app's control, or the built-in [`Toggle`].
128    Custom(Arc<dyn Control>),
129    /// A row of an item's controls per value of a `#[document]` list.
130    Repeater(RepeaterControl),
131}
132
133impl std::fmt::Debug for Field {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        let control = match &self.control {
136            ControlKind::Text(_) => "text",
137            ControlKind::Choice(_) => "choice",
138            ControlKind::File => "file",
139            ControlKind::Custom(_) => "custom",
140            ControlKind::Repeater(_) => "repeater",
141        };
142        f.debug_struct("Field")
143            .field("name", &self.name())
144            .field("label", &self.label_str())
145            .field("required", &self.required)
146            .field("control", &control)
147            .field("condition", &self.condition)
148            .field("help", &self.help)
149            .field("disabled", &self.disabled)
150            .field("default", &self.default)
151            .finish()
152    }
153}
154
155impl Field {
156    fn bound(binding: Binding, control: ControlKind) -> Self {
157        Self {
158            binding,
159            label: None,
160            required: false,
161            control,
162            condition: None,
163            checkbox: false,
164            help: None,
165            disabled: Disabled::Never,
166            default: None,
167        }
168    }
169
170    /// Binds any [`FormScalar`] column, storing the type's own spelling and probing uniqueness
171    /// through the lens.
172    pub fn text<M, T>(lens: impl Into<Path<M, T>>) -> TextField
173    where
174        M: toasty::schema::Model,
175        T: FormScalar + toasty::stmt::IntoExpr<T> + 'static,
176    {
177        let path: Path<M, T> = lens.into();
178        let binding = Binding::of(&path);
179        let control = TextControl::new::<M, T>(path, binding.unique());
180        TextField::new(Self::bound(binding, ControlKind::Text(control)))
181    }
182
183    /// The text field `#[derive(EmbeddedForm)]` renders for a leaf.
184    #[doc(hidden)]
185    pub fn embedded_leaf<M, T>(path: Path<M, T>) -> TextField
186    where
187        M: toasty::schema::Model,
188        T: FormScalar,
189    {
190        TextField::new(Self::bound(
191            Binding::of(&path),
192            ControlKind::Text(TextControl::leaf::<T>()),
193        ))
194    }
195
196    /// A choice field over any column, with options from [`options`](ChoiceField::options) or
197    /// [`relationship`](ChoiceField::relationship).
198    pub fn choice<M, T>(lens: impl Into<Path<M, T>>) -> ChoiceField
199    where
200        M: toasty::schema::Model,
201    {
202        ChoiceField::new(Self::bound(
203            Binding::of::<M, T>(&lens.into()),
204            ControlKind::Choice(ChoiceControl::default()),
205        ))
206    }
207
208    /// A file field over a `String` column holding the uploaded path, rendering no `value`
209    /// attribute.
210    pub fn file<M>(lens: impl Into<Path<M, String>>) -> FileField
211    where
212        M: toasty::schema::Model,
213    {
214        FileField::new(Self::bound(
215            Binding::of::<M, String>(&lens.into()),
216            ControlKind::File,
217        ))
218    }
219
220    /// A checkbox over a `bool` column that submits `false` when unchecked.
221    pub fn toggle<M>(lens: impl Into<Path<M, bool>>) -> CustomField
222    where
223        M: toasty::schema::Model,
224    {
225        let mut field = Self::custom(lens, Toggle);
226        field.0.checkbox = true;
227        field
228    }
229
230    /// A field over any [`FormScalar`] column, rendered by an app's [`Control`].
231    pub fn custom<M, T>(lens: impl Into<Path<M, T>>, control: impl Control + 'static) -> CustomField
232    where
233        M: toasty::schema::Model,
234        T: FormScalar,
235    {
236        CustomField::new(Self::bound(
237            Binding::of::<M, T>(&lens.into()),
238            ControlKind::Custom(Arc::new(control)),
239        ))
240    }
241
242    /// A row of `T`'s controls per item of a `#[document]` list of `T`, which the browser adds,
243    /// removes and moves: a record form's `#[form(repeat)]` control.
244    ///
245    /// Each row posts its item's keys under `{key}.{row}.`. A resource's form and an action's
246    /// input read them into `key`'s rows, and a page handling its own post reads it with
247    /// [`Schema::read`](crate::Schema::read); the items then parse with
248    /// [`parse_items`](crate::schema::parse_items).
249    pub fn repeater<M, T>(lens: impl Into<Path<M, List<T>>>) -> RepeaterField
250    where
251        M: toasty::schema::Model,
252        T: RepeaterItem,
253    {
254        RepeaterField::new(Self::bound(
255            Binding::of::<M, List<T>>(&lens.into()),
256            ControlKind::Repeater(RepeaterControl::new::<T>()),
257        ))
258    }
259
260    /// A text field posting `name`, a key no column binds: an [`ActionInput`](crate::ActionInput)
261    /// field. `T` picks the input type, as a column's type does for [`Field::text`].
262    pub fn text_input<T: FormScalar>(name: impl Into<String>) -> TextField {
263        TextField::new(Self::bound(
264            Self::named(name.into()),
265            ControlKind::Text(TextControl::leaf::<T>()),
266        ))
267    }
268
269    /// A choice field posting `name`, a key no column binds, with options from
270    /// [`options`](ChoiceField::options).
271    pub fn choice_input(name: impl Into<String>) -> ChoiceField {
272        ChoiceField::new(Self::bound(
273            Self::named(name.into()),
274            ControlKind::Choice(ChoiceControl::default()),
275        ))
276    }
277
278    /// A checkbox posting `name`, a key no column binds, that submits `false` when unchecked.
279    pub fn toggle_input(name: impl Into<String>) -> CustomField {
280        let mut field = Self::bound(
281            Self::named(name.into()),
282            ControlKind::Custom(Arc::new(Toggle)),
283        );
284        field.checkbox = true;
285        CustomField::new(field)
286    }
287
288    /// A binding for `name`, labelled as a column of that name would be.
289    fn named(name: String) -> Binding {
290        let label = capitalize(&name);
291        Binding::named(name, label)
292    }
293
294    /// The variant control of an embedded enum, never required since an empty submit reads the
295    /// variant from the payload.
296    pub(crate) fn discriminant(name: String, variants: Vec<(String, String)>) -> Self {
297        Self {
298            binding: Binding::named(name.clone(), capitalize(&name)),
299            label: None,
300            required: false,
301            control: ControlKind::Choice(ChoiceControl {
302                discriminant: true,
303                options: variants,
304                ..ChoiceControl::default()
305            }),
306            condition: None,
307            checkbox: false,
308            help: None,
309            disabled: Disabled::Never,
310            default: None,
311        }
312    }
313
314    /// Bind an embedded path through `resolver`'s app schema.
315    pub(crate) fn bind(&self, resolver: &FieldResolver) {
316        self.binding.bind(resolver);
317    }
318
319    pub(crate) fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
320        self.binding.misdeclared()
321    }
322
323    /// The key the field posts: the storage column its path names.
324    pub fn name(&self) -> &str {
325        self.binding.name()
326    }
327
328    pub(crate) fn label_str(&self) -> &str {
329        self.label.as_deref().unwrap_or(self.binding.label())
330    }
331
332    /// Whether the field is a choice declaring neither options nor a relationship.
333    pub(crate) fn offers_nothing(&self) -> bool {
334        self.as_choice().is_some_and(ChoiceControl::offers_nothing)
335    }
336
337    pub(crate) fn as_repeater(&self) -> Option<&RepeaterControl> {
338        match &self.control {
339            ControlKind::Repeater(repeater) => Some(repeater),
340            _ => None,
341        }
342    }
343
344    /// Posts under `prefix`: a repeater row's control, keeping its label.
345    pub(crate) fn prefix(&mut self, prefix: &str) {
346        let label = self.label_str().to_string();
347        self.binding = Binding::named(format!("{prefix}{}", self.name()), label);
348    }
349
350    pub(crate) fn as_choice(&self) -> Option<&ChoiceControl> {
351        match &self.control {
352            ControlKind::Choice(choice) => Some(choice),
353            _ => None,
354        }
355    }
356
357    /// Probes uniqueness with the submission parsed through the lens.
358    pub(crate) fn eq_filter(&self, value: &str) -> Option<toasty::stmt::Expr<bool>> {
359        match &self.control {
360            ControlKind::Text(text) => text.eq_filter(value),
361            _ => None,
362        }
363    }
364
365    pub(crate) fn is_file(&self) -> bool {
366        matches!(self.control, ControlKind::File)
367    }
368
369    pub(crate) fn is_unique(&self) -> bool {
370        matches!(&self.control, ControlKind::Text(text) if text.unique)
371    }
372
373    /// The condition showing the field, if any.
374    pub(crate) fn condition(&self) -> Option<&Condition> {
375        self.condition.as_ref()
376    }
377
378    /// Shows the field only while `condition` holds, as `visible_when` does.
379    pub(crate) fn set_condition(&mut self, condition: Condition) {
380        self.condition = Some(condition);
381    }
382
383    /// Whether the control is a checkbox.
384    pub(crate) fn is_checkbox(&self) -> bool {
385        self.checkbox
386    }
387
388    /// Whether the control can post `value`: a checkbox posts `true` or `false`, and a choice over
389    /// static options one of their values. `None` when the control does not say: a text field, a
390    /// relationship, or an app's own control.
391    pub(crate) fn can_post(&self, value: &str) -> Option<bool> {
392        if self.checkbox {
393            return Some(value == "true" || value == "false");
394        }
395        match &self.control {
396            // Its key holds a list, which no condition value spells.
397            ControlKind::Choice(choice) if choice.is_multiple() => Some(false),
398            ControlKind::Choice(choice) if !choice.is_relationship() => {
399                Some(value.is_empty() || choice.label_of(value).is_some())
400            }
401            _ => None,
402        }
403    }
404
405    /// Whether the control could never post `value`: a typed text field's type does not parse
406    /// it, a choice over static options or a checkbox does not offer it, or it is a list where
407    /// the control posts one value, or one value where it posts a list.
408    pub(crate) fn refuses(&self, value: &FormValue) -> bool {
409        match (&self.control, value) {
410            (ControlKind::Choice(choice), FormValue::List(items)) if choice.is_multiple() => {
411                !choice.is_relationship()
412                    && items.iter().any(|item| choice.label_of(item).is_none())
413            }
414            (ControlKind::Choice(choice), _) if choice.is_multiple() => true,
415            (ControlKind::Text(text), FormValue::Text(value)) => {
416                !value.trim().is_empty() && !text.parses(value)
417            }
418            (_, FormValue::Text(value)) => self.can_post(value) == Some(false),
419            _ => true,
420        }
421    }
422
423    /// Whether the control renders as required.
424    pub(crate) fn is_required(&self) -> bool {
425        self.required
426    }
427
428    /// Whether the control renders disabled, on the edit form when `editing`.
429    pub(crate) fn is_disabled(&self, editing: bool) -> bool {
430        match self.disabled {
431            Disabled::Never => false,
432            Disabled::Always => true,
433            Disabled::OnEdit => editing,
434        }
435    }
436
437    /// Whether the control renders disabled on the create form as well as the edit form.
438    pub(crate) fn is_always_disabled(&self) -> bool {
439        self.disabled == Disabled::Always
440    }
441
442    /// The create form's initial value, as the control posts it.
443    pub(crate) fn default_value(&self) -> Option<&FormValue> {
444        self.default.as_ref()
445    }
446
447    /// The validation state and help text the control renders with.
448    fn chrome(
449        &self,
450        id: String,
451        error: Option<&FieldError>,
452        fallback: Option<String>,
453    ) -> FieldChrome {
454        let mut chrome = FieldChrome::new(id, error, self.label_str(), fallback);
455        chrome.help = self.help.clone();
456        chrome
457    }
458
459    /// Renders the control required when its record-form field has no blank answer.
460    pub(crate) fn set_required(&mut self, required: bool) {
461        self.required = required;
462    }
463
464    /// Whether the bound column stores NULL, which a unique index admits any number of times.
465    pub(crate) fn is_nullable(&self) -> bool {
466        self.binding.nullable()
467    }
468
469    /// Refuses a non-empty submission the control's own rule refuses: an email field's address.
470    pub(crate) fn check(&self, value: &FormValue) -> Option<FieldError> {
471        let value = value.as_text()?.trim();
472        match &self.control {
473            ControlKind::Text(text) if text.email && !value.is_empty() && !is_email(value) => {
474                Some(FieldError::invalid(
475                    self.name(),
476                    format!("{} must be a valid email", self.label_str()),
477                ))
478            }
479            _ => None,
480        }
481    }
482
483    /// Whether two submissions spell the same stored value, as the unique check compares them.
484    pub(crate) fn same_value(&self, a: &str, b: &str) -> bool {
485        match &self.control {
486            ControlKind::Text(text) => text.same_value(a, b),
487            _ => a.trim() == b.trim(),
488        }
489    }
490
491    /// The key of the field whose value narrows a dependent choice's options.
492    pub(crate) fn parent_key(&self) -> Option<&str> {
493        self.as_choice().and_then(ChoiceControl::parent_key)
494    }
495
496    /// Whether a submitted choice matches the options its parent's value `parent` offers; a
497    /// multiple choice checks only the keys its stored value `stored` does not hold.
498    pub(crate) async fn validate_exists(
499        &self,
500        cx: &Cx,
501        value: &FormValue,
502        parent: Option<&str>,
503        stored: Option<&FormValue>,
504    ) -> Vec<String> {
505        match &self.control {
506            ControlKind::Choice(choice) => {
507                choice
508                    .validate_exists(cx, self.label_str(), value, parent, stored)
509                    .await
510            }
511            _ => Vec::new(),
512        }
513    }
514
515    /// Re-checks a relationship choice in the write's transaction; a multiple choice re-checks
516    /// only the keys its stored value `stored` does not hold.
517    pub(crate) async fn recheck(
518        &self,
519        cx: &Cx,
520        value: &FormValue,
521        parent: Option<&str>,
522        stored: Option<&FormValue>,
523        ex: &mut dyn toasty::Executor,
524    ) -> Vec<String> {
525        match &self.control {
526            ControlKind::Choice(choice) => {
527                choice
528                    .recheck(cx, self.label_str(), value, parent, stored, ex)
529                    .await
530            }
531            _ => Vec::new(),
532        }
533    }
534
535    /// A multiple choice's `value` with the stored keys it drops that the user cannot view put
536    /// back, or `None` when it drops none.
537    pub(crate) async fn keep_unseen(
538        &self,
539        cx: &Cx,
540        value: &FormValue,
541        stored: &FormValue,
542        ex: &mut dyn toasty::Executor,
543    ) -> Option<FormValue> {
544        match &self.control {
545            ControlKind::Choice(choice) => choice.keep_unseen(cx, value, stored, ex).await,
546            _ => None,
547        }
548    }
549
550    /// Renders the field's control holding the text `value` alone on its page, a dependent choice
551    /// with no parent value.
552    #[cfg(test)]
553    pub(crate) async fn render<'a>(
554        &self,
555        cx: &'a Cx,
556        value: Option<&str>,
557        error: Option<&FieldError>,
558    ) -> Result<BoxView<'a>> {
559        let value = value.map(FormValue::from);
560        self.render_under(cx, value.as_ref(), error, Placement::default())
561            .await
562    }
563
564    /// Renders the field's control where `placement` puts it.
565    pub(crate) async fn render_under<'a>(
566        &self,
567        cx: &'a Cx,
568        value: Option<&FormValue>,
569        error: Option<&FieldError>,
570        placement: Placement<'_>,
571    ) -> Result<BoxView<'a>> {
572        let id = placement.id(self.name());
573        let disabled = self.is_disabled(placement.editing);
574        let text = value.and_then(FormValue::as_text);
575        match &self.control {
576            ControlKind::Text(control) => self.render_text(control, cx, text, error, id, disabled),
577            ControlKind::Choice(choice) => {
578                Box::pin(self.render_choice(choice, cx, value, error, id, placement)).await
579            }
580            ControlKind::File => self.render_file(cx, text, error, id, disabled),
581            ControlKind::Custom(control) => {
582                self.render_custom(control.as_ref(), cx, text, error, id, disabled)
583            }
584            // Its node renders it, from the keys and errors of each row's controls.
585            ControlKind::Repeater(_) => Ok(().boxed()),
586        }
587    }
588
589    /// The stored `value` as a reader sees it: a choice's option label, else the value itself.
590    /// `None` for a variant control whose value names no variant.
591    pub(crate) fn read(&self, value: &str) -> Option<String> {
592        match &self.control {
593            ControlKind::Choice(choice) => {
594                let stored = value.trim();
595                match choice.label_of(stored) {
596                    Some(label) => Some(label.to_string()),
597                    None if choice.discriminant => None,
598                    None => Some(stored.to_string()),
599                }
600            }
601            _ => Some(value.to_string()),
602        }
603    }
604
605    /// Renders the label over the stored `value`, [read](Self::read) rather than edited: an
606    /// embedded value's leaf or variant on a detail page.
607    pub(crate) fn display<'a>(&self, cx: &'a Cx, value: &str) -> BoxView<'a> {
608        match self.read(value) {
609            Some(text) => read_only(cx, self.label_str(), value_cell(cx, &text)),
610            None => ().boxed(),
611        }
612    }
613
614    /// Renders an app [`Control`] in its shared chrome.
615    fn render_custom<'a>(
616        &self,
617        control: &dyn Control,
618        cx: &'a Cx,
619        value: Option<&str>,
620        error: Option<&FieldError>,
621        id: String,
622        disabled: bool,
623    ) -> Result<BoxView<'a>> {
624        let required = self.is_required() && !disabled;
625        let chrome = self.chrome(id, error, None);
626        let input = ControlInput::new(
627            self.name(),
628            &chrome.id,
629            value,
630            required,
631            chrome.aria_invalid() == "true",
632            chrome.described_by(),
633        )
634        .disabled(disabled);
635        let rendered = control.render(cx, input);
636        render_field(
637            cx,
638            &chrome,
639            self.label_str(),
640            required,
641            attributes! {},
642            rendered,
643        )
644    }
645}
646
647/// A read-only value: `label` over `value`, which wraps as text. A detail page's entry, and an
648/// embedded leaf on one.
649pub(crate) fn read_only<'a>(cx: &'a Cx, label: &str, value: BoxView<'a>) -> BoxView<'a> {
650    let label = label.to_string();
651    view! {
652        cx =>
653        ui_field(
654            attrs: attributes! { class="ac-field" },
655            ui_field_content(
656                ui_field_title((label))
657                <div class="text-sm text-foreground wrap-anywhere whitespace-pre-wrap">
658                    (value)
659                </div>
660            )
661        )
662    }
663    .boxed()
664}
665
666/// A record's value as read-only text: a blank value reads as a muted dash.
667pub(crate) fn value_cell<'a>(cx: &'a Cx, text: &str) -> BoxView<'a> {
668    if text.trim().is_empty() {
669        return view! { cx => <span class="text-muted-foreground">"—"</span> }.boxed();
670    }
671    let text = text.to_string();
672    view! { cx => (text) }.boxed()
673}
674
675/// Where a field's control renders: under its parent's value, and in which form of its page.
676#[derive(Debug, Clone, Copy, Default)]
677pub(crate) struct Placement<'p> {
678    /// The value the field's parent posts: a dependent choice offers the rows it selects.
679    pub(crate) parent: Option<&'p str>,
680    /// The prefix of the form's DOM ids, so several forms on one page never share one; empty
681    /// for a form alone on its page.
682    pub(crate) scope: &'p str,
683    /// The URL a searchable choice fetches its options from; `None` for the resource's own
684    /// `{list}/-/options`, which the browser derives from the page's URL.
685    pub(crate) options: Option<&'p str>,
686    /// Whether the form edits a stored record, where `disabled_on_edit` disables a control.
687    pub(crate) editing: bool,
688}
689
690impl Placement<'_> {
691    /// The DOM id of the control posting `name`.
692    pub(crate) fn id(&self, name: &str) -> String {
693        if self.scope.is_empty() {
694            name.to_string()
695        } else {
696            format!("{}-{name}", self.scope)
697        }
698    }
699}
700
701/// The validation state a form control renders.
702pub(crate) struct FieldChrome {
703    /// The control's DOM id, which the label names.
704    pub(crate) id: String,
705    error_id: String,
706    error_text: String,
707    /// Why the control renders an error, `None` when it renders none.
708    error_kind: Option<&'static str>,
709    /// The help text under the control.
710    help: Option<String>,
711}
712
713/// The `data-error` an error slot carries, naming why the control was refused: a test reads it
714/// instead of the sentence.
715pub(crate) fn error_kind(error: &FieldError) -> &'static str {
716    match error.kind {
717        FieldErrorKind::Required => "required",
718        FieldErrorKind::Invalid(_) => "invalid",
719    }
720}
721
722/// The `data-error` of a choice whose options the user may not read.
723pub(crate) const UNAVAILABLE: &str = "unavailable";
724
725impl FieldChrome {
726    /// The chrome of the control labelled `label`, refused with `error`. `fallback` is the
727    /// message of a choice whose options the user may not read: its slot reads unavailable
728    /// whatever refused the submission, and shows `fallback` when nothing did.
729    pub(crate) fn new(
730        id: String,
731        error: Option<&FieldError>,
732        label: &str,
733        fallback: Option<String>,
734    ) -> Self {
735        let message = error.map(|error| error.message(label));
736        let (error_text, error_kind) = match (message, fallback) {
737            (Some(message), Some(_)) if !message.is_empty() => (message, Some(UNAVAILABLE)),
738            (Some(message), None) => (message, error.map(error_kind)),
739            (_, Some(fallback)) => (fallback, Some(UNAVAILABLE)),
740            (None, None) => (String::new(), None),
741        };
742        Self {
743            error_id: format!("{id}-error"),
744            id,
745            error_text,
746            error_kind,
747            help: None,
748        }
749    }
750
751    fn has_error(&self) -> bool {
752        self.error_kind.is_some()
753    }
754
755    fn help_id(&self) -> String {
756        format!("{}-description", self.id)
757    }
758
759    pub(crate) fn aria_invalid(&self) -> &'static str {
760        if self.has_error() { "true" } else { "false" }
761    }
762
763    /// The ids of the help text and the error, whichever render.
764    pub(crate) fn described_by(&self) -> Option<String> {
765        let ids: Vec<String> = [
766            self.help.is_some().then(|| self.help_id()),
767            self.has_error().then(|| self.error_id.clone()),
768        ]
769        .into_iter()
770        .flatten()
771        .collect();
772        (!ids.is_empty()).then(|| ids.join(" "))
773    }
774}
775
776/// The chrome every form control renders: wrapper, label, control, and error slot.
777pub(crate) fn render_field<'a>(
778    cx: &'a Cx,
779    chrome: &FieldChrome,
780    label: &str,
781    required: bool,
782    attributes: Attributes,
783    control: BoxView<'a>,
784) -> Result<BoxView<'a>> {
785    let id = chrome.id.clone();
786    let label_text = label.to_string();
787    let has_error = chrome.has_error();
788    let error_kind = chrome.error_kind;
789    let error_id = chrome.error_id.clone();
790    let error_text = chrome.error_text.clone();
791    let help = chrome.help.clone();
792    let help_id = chrome.help_id();
793    let field_class = if has_error {
794        "ac-field ac-field--error"
795    } else {
796        "ac-field"
797    };
798    Ok(view! {
799        cx =>
800        ui_field(
801            attrs: attributes! {
802                class=(field_class)
803                data-invalid=(has_error.then_some("true"))
804                (attributes)
805            },
806            ui_field_label(
807                attrs: attributes! { for=(id) },
808                (label_text)
809                if required {
810                    <span class="text-destructive" aria-hidden="true">"*"</span>
811                }
812            )
813            (control)
814            if let Some(help) = help {
815                ui_field_description(attrs: attributes! { id=(help_id) }, (help))
816            }
817            if has_error {
818                ui_field_error(
819                    attrs: attributes! {
820                        id=(error_id)
821                        class="ac-error"
822                        aria-live="polite"
823                        data-error=(error_kind)
824                    },
825                    (error_text)
826                )
827            }
828        )
829    }
830    .boxed())
831}
832
833#[cfg(test)]
834mod test_support {
835    pub(super) use crate::test_support::{DummyUser, cx};
836
837    /// The opening tag carrying `needle` (Topcoat renders attributes in no fixed order).
838    pub(super) fn tag_with<'h>(html: &'h str, needle: &str) -> &'h str {
839        let at = html
840            .find(needle)
841            .unwrap_or_else(|| panic!("no {needle} in {html}"));
842        opening_tag_at(html, html[..at].rfind('<').expect("its opening tag"))
843    }
844
845    /// Slices the opening tag at `start`, honouring quoting.
846    pub(super) fn opening_tag_at(html: &str, start: usize) -> &str {
847        let mut quoted = false;
848        for (offset, byte) in html.as_bytes()[start..].iter().enumerate() {
849            match byte {
850                b'"' => quoted = !quoted,
851                b'>' if !quoted => return &html[start..start + offset],
852                _ => {}
853            }
854        }
855        panic!("unterminated tag at byte {start} in {html}");
856    }
857}
858
859#[cfg(test)]
860mod tests;