mod builders;
mod choice;
pub(crate) mod custom;
mod file;
mod text;
use std::sync::Arc;
pub use builders::{
ChoiceField, CustomField, FileField, IntoFormValue, IntoOptions, RepeaterField, TextField,
};
pub(crate) use choice::{ChoiceControl, Relationship, option_view};
pub use custom::Toggle;
pub(crate) use custom::{Control, ControlInput};
pub(crate) use file::stored_upload;
use tablo_ui::{
field as ui_field, field_content as ui_field_content,
field_description as ui_field_description, field_error as ui_field_error,
field_label as ui_field_label, field_title as ui_field_title,
};
pub(crate) use text::TextControl;
use toasty::stmt::{List, Path};
use topcoat::{Result, context::Cx, view::*};
use super::{
condition::Condition,
lenses::{Binding, FieldResolver},
repeater::{RepeaterControl, RepeaterItem},
validation::is_email,
};
use crate::{
form::{FieldError, FieldErrorKind, FormScalar, FormValue},
naming::capitalize,
};
pub struct Field {
binding: Binding,
label: Option<String>,
required: bool,
control: ControlKind,
condition: Option<Condition>,
checkbox: bool,
help: Option<String>,
disabled: Disabled,
default: Option<FormValue>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) enum Disabled {
#[default]
Never,
Always,
OnEdit,
}
pub(crate) enum ControlKind {
Text(TextControl),
Choice(ChoiceControl),
File,
Custom(Arc<dyn Control>),
Repeater(RepeaterControl),
}
impl std::fmt::Debug for Field {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let control = match &self.control {
ControlKind::Text(_) => "text",
ControlKind::Choice(_) => "choice",
ControlKind::File => "file",
ControlKind::Custom(_) => "custom",
ControlKind::Repeater(_) => "repeater",
};
f.debug_struct("Field")
.field("name", &self.name())
.field("label", &self.label_str())
.field("required", &self.required)
.field("control", &control)
.field("condition", &self.condition)
.field("help", &self.help)
.field("disabled", &self.disabled)
.field("default", &self.default)
.finish()
}
}
impl Field {
fn bound(binding: Binding, control: ControlKind) -> Self {
Self {
binding,
label: None,
required: false,
control,
condition: None,
checkbox: false,
help: None,
disabled: Disabled::Never,
default: None,
}
}
pub fn text<M, T>(lens: impl Into<Path<M, T>>) -> TextField
where
M: toasty::schema::Model,
T: FormScalar + toasty::stmt::IntoExpr<T> + 'static,
{
let path: Path<M, T> = lens.into();
let binding = Binding::of(&path);
let control = TextControl::new::<M, T>(path, binding.unique());
TextField::new(Self::bound(binding, ControlKind::Text(control)))
}
#[doc(hidden)]
pub fn embedded_leaf<M, T>(path: Path<M, T>) -> TextField
where
M: toasty::schema::Model,
T: FormScalar,
{
TextField::new(Self::bound(
Binding::of(&path),
ControlKind::Text(TextControl::leaf::<T>()),
))
}
pub fn choice<M, T>(lens: impl Into<Path<M, T>>) -> ChoiceField
where
M: toasty::schema::Model,
{
ChoiceField::new(Self::bound(
Binding::of::<M, T>(&lens.into()),
ControlKind::Choice(ChoiceControl::default()),
))
}
pub fn file<M>(lens: impl Into<Path<M, String>>) -> FileField
where
M: toasty::schema::Model,
{
FileField::new(Self::bound(
Binding::of::<M, String>(&lens.into()),
ControlKind::File,
))
}
pub fn toggle<M>(lens: impl Into<Path<M, bool>>) -> CustomField
where
M: toasty::schema::Model,
{
let mut field = Self::custom(lens, Toggle);
field.0.checkbox = true;
field
}
pub fn custom<M, T>(lens: impl Into<Path<M, T>>, control: impl Control + 'static) -> CustomField
where
M: toasty::schema::Model,
T: FormScalar,
{
CustomField::new(Self::bound(
Binding::of::<M, T>(&lens.into()),
ControlKind::Custom(Arc::new(control)),
))
}
pub fn repeater<M, T>(lens: impl Into<Path<M, List<T>>>) -> RepeaterField
where
M: toasty::schema::Model,
T: RepeaterItem,
{
RepeaterField::new(Self::bound(
Binding::of::<M, List<T>>(&lens.into()),
ControlKind::Repeater(RepeaterControl::new::<T>()),
))
}
pub fn text_input<T: FormScalar>(name: impl Into<String>) -> TextField {
TextField::new(Self::bound(
Self::named(name.into()),
ControlKind::Text(TextControl::leaf::<T>()),
))
}
pub fn choice_input(name: impl Into<String>) -> ChoiceField {
ChoiceField::new(Self::bound(
Self::named(name.into()),
ControlKind::Choice(ChoiceControl::default()),
))
}
pub fn toggle_input(name: impl Into<String>) -> CustomField {
let mut field = Self::bound(
Self::named(name.into()),
ControlKind::Custom(Arc::new(Toggle)),
);
field.checkbox = true;
CustomField::new(field)
}
fn named(name: String) -> Binding {
let label = capitalize(&name);
Binding::named(name, label)
}
pub(crate) fn discriminant(name: String, variants: Vec<(String, String)>) -> Self {
Self {
binding: Binding::named(name.clone(), capitalize(&name)),
label: None,
required: false,
control: ControlKind::Choice(ChoiceControl {
discriminant: true,
options: variants,
..ChoiceControl::default()
}),
condition: None,
checkbox: false,
help: None,
disabled: Disabled::Never,
default: None,
}
}
pub(crate) fn bind(&self, resolver: &FieldResolver) {
self.binding.bind(resolver);
}
pub(crate) fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
self.binding.misdeclared()
}
pub fn name(&self) -> &str {
self.binding.name()
}
pub(crate) fn label_str(&self) -> &str {
self.label.as_deref().unwrap_or(self.binding.label())
}
pub(crate) fn offers_nothing(&self) -> bool {
self.as_choice().is_some_and(ChoiceControl::offers_nothing)
}
pub(crate) fn as_repeater(&self) -> Option<&RepeaterControl> {
match &self.control {
ControlKind::Repeater(repeater) => Some(repeater),
_ => None,
}
}
pub(crate) fn prefix(&mut self, prefix: &str) {
let label = self.label_str().to_string();
self.binding = Binding::named(format!("{prefix}{}", self.name()), label);
}
pub(crate) fn as_choice(&self) -> Option<&ChoiceControl> {
match &self.control {
ControlKind::Choice(choice) => Some(choice),
_ => None,
}
}
pub(crate) fn eq_filter(&self, value: &str) -> Option<toasty::stmt::Expr<bool>> {
match &self.control {
ControlKind::Text(text) => text.eq_filter(value),
_ => None,
}
}
pub(crate) fn is_file(&self) -> bool {
matches!(self.control, ControlKind::File)
}
pub(crate) fn is_unique(&self) -> bool {
matches!(&self.control, ControlKind::Text(text) if text.unique)
}
pub(crate) fn condition(&self) -> Option<&Condition> {
self.condition.as_ref()
}
pub(crate) fn set_condition(&mut self, condition: Condition) {
self.condition = Some(condition);
}
pub(crate) fn is_checkbox(&self) -> bool {
self.checkbox
}
pub(crate) fn can_post(&self, value: &str) -> Option<bool> {
if self.checkbox {
return Some(value == "true" || value == "false");
}
match &self.control {
ControlKind::Choice(choice) if choice.is_multiple() => Some(false),
ControlKind::Choice(choice) if !choice.is_relationship() => {
Some(value.is_empty() || choice.label_of(value).is_some())
}
_ => None,
}
}
pub(crate) fn refuses(&self, value: &FormValue) -> bool {
match (&self.control, value) {
(ControlKind::Choice(choice), FormValue::List(items)) if choice.is_multiple() => {
!choice.is_relationship()
&& items.iter().any(|item| choice.label_of(item).is_none())
}
(ControlKind::Choice(choice), _) if choice.is_multiple() => true,
(ControlKind::Text(text), FormValue::Text(value)) => {
!value.trim().is_empty() && !text.parses(value)
}
(_, FormValue::Text(value)) => self.can_post(value) == Some(false),
_ => true,
}
}
pub(crate) fn is_required(&self) -> bool {
self.required
}
pub(crate) fn is_disabled(&self, editing: bool) -> bool {
match self.disabled {
Disabled::Never => false,
Disabled::Always => true,
Disabled::OnEdit => editing,
}
}
pub(crate) fn is_always_disabled(&self) -> bool {
self.disabled == Disabled::Always
}
pub(crate) fn default_value(&self) -> Option<&FormValue> {
self.default.as_ref()
}
fn chrome(
&self,
id: String,
error: Option<&FieldError>,
fallback: Option<String>,
) -> FieldChrome {
let mut chrome = FieldChrome::new(id, error, self.label_str(), fallback);
chrome.help = self.help.clone();
chrome
}
pub(crate) fn set_required(&mut self, required: bool) {
self.required = required;
}
pub(crate) fn is_nullable(&self) -> bool {
self.binding.nullable()
}
pub(crate) fn check(&self, value: &FormValue) -> Option<FieldError> {
let value = value.as_text()?.trim();
match &self.control {
ControlKind::Text(text) if text.email && !value.is_empty() && !is_email(value) => {
Some(FieldError::invalid(
self.name(),
format!("{} must be a valid email", self.label_str()),
))
}
_ => None,
}
}
pub(crate) fn same_value(&self, a: &str, b: &str) -> bool {
match &self.control {
ControlKind::Text(text) => text.same_value(a, b),
_ => a.trim() == b.trim(),
}
}
pub(crate) fn parent_key(&self) -> Option<&str> {
self.as_choice().and_then(ChoiceControl::parent_key)
}
pub(crate) async fn validate_exists(
&self,
cx: &Cx,
value: &FormValue,
parent: Option<&str>,
stored: Option<&FormValue>,
) -> Vec<String> {
match &self.control {
ControlKind::Choice(choice) => {
choice
.validate_exists(cx, self.label_str(), value, parent, stored)
.await
}
_ => Vec::new(),
}
}
pub(crate) async fn recheck(
&self,
cx: &Cx,
value: &FormValue,
parent: Option<&str>,
stored: Option<&FormValue>,
ex: &mut dyn toasty::Executor,
) -> Vec<String> {
match &self.control {
ControlKind::Choice(choice) => {
choice
.recheck(cx, self.label_str(), value, parent, stored, ex)
.await
}
_ => Vec::new(),
}
}
pub(crate) async fn keep_unseen(
&self,
cx: &Cx,
value: &FormValue,
stored: &FormValue,
ex: &mut dyn toasty::Executor,
) -> Option<FormValue> {
match &self.control {
ControlKind::Choice(choice) => choice.keep_unseen(cx, value, stored, ex).await,
_ => None,
}
}
#[cfg(test)]
pub(crate) async fn render<'a>(
&self,
cx: &'a Cx,
value: Option<&str>,
error: Option<&FieldError>,
) -> Result<BoxView<'a>> {
let value = value.map(FormValue::from);
self.render_under(cx, value.as_ref(), error, Placement::default())
.await
}
pub(crate) async fn render_under<'a>(
&self,
cx: &'a Cx,
value: Option<&FormValue>,
error: Option<&FieldError>,
placement: Placement<'_>,
) -> Result<BoxView<'a>> {
let id = placement.id(self.name());
let disabled = self.is_disabled(placement.editing);
let text = value.and_then(FormValue::as_text);
match &self.control {
ControlKind::Text(control) => self.render_text(control, cx, text, error, id, disabled),
ControlKind::Choice(choice) => {
Box::pin(self.render_choice(choice, cx, value, error, id, placement)).await
}
ControlKind::File => self.render_file(cx, text, error, id, disabled),
ControlKind::Custom(control) => {
self.render_custom(control.as_ref(), cx, text, error, id, disabled)
}
ControlKind::Repeater(_) => Ok(().boxed()),
}
}
pub(crate) fn read(&self, value: &str) -> Option<String> {
match &self.control {
ControlKind::Choice(choice) => {
let stored = value.trim();
match choice.label_of(stored) {
Some(label) => Some(label.to_string()),
None if choice.discriminant => None,
None => Some(stored.to_string()),
}
}
_ => Some(value.to_string()),
}
}
pub(crate) fn display<'a>(&self, cx: &'a Cx, value: &str) -> BoxView<'a> {
match self.read(value) {
Some(text) => read_only(cx, self.label_str(), value_cell(cx, &text)),
None => ().boxed(),
}
}
fn render_custom<'a>(
&self,
control: &dyn Control,
cx: &'a Cx,
value: Option<&str>,
error: Option<&FieldError>,
id: String,
disabled: bool,
) -> Result<BoxView<'a>> {
let required = self.is_required() && !disabled;
let chrome = self.chrome(id, error, None);
let input = ControlInput::new(
self.name(),
&chrome.id,
value,
required,
chrome.aria_invalid() == "true",
chrome.described_by(),
)
.disabled(disabled);
let rendered = control.render(cx, input);
render_field(
cx,
&chrome,
self.label_str(),
required,
attributes! {},
rendered,
)
}
}
pub(crate) fn read_only<'a>(cx: &'a Cx, label: &str, value: BoxView<'a>) -> BoxView<'a> {
let label = label.to_string();
view! {
cx =>
ui_field(
attrs: attributes! { class="ac-field" },
ui_field_content(
ui_field_title((label))
<div class="text-sm text-foreground wrap-anywhere whitespace-pre-wrap">
(value)
</div>
)
)
}
.boxed()
}
pub(crate) fn value_cell<'a>(cx: &'a Cx, text: &str) -> BoxView<'a> {
if text.trim().is_empty() {
return view! { cx => <span class="text-muted-foreground">"—"</span> }.boxed();
}
let text = text.to_string();
view! { cx => (text) }.boxed()
}
/// Where a field's control renders: under its parent's value, and in which form of its page.
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct Placement<'p> {
/// The value the field's parent posts: a dependent choice offers the rows it selects.
pub(crate) parent: Option<&'p str>,
/// The prefix of the form's DOM ids, so several forms on one page never share one; empty
/// for a form alone on its page.
pub(crate) scope: &'p str,
/// The URL a searchable choice fetches its options from; `None` for the resource's own
/// `{list}/-/options`, which the browser derives from the page's URL.
pub(crate) options: Option<&'p str>,
/// Whether the form edits a stored record, where `disabled_on_edit` disables a control.
pub(crate) editing: bool,
}
impl Placement<'_> {
/// The DOM id of the control posting `name`.
pub(crate) fn id(&self, name: &str) -> String {
if self.scope.is_empty() {
name.to_string()
} else {
format!("{}-{name}", self.scope)
}
}
}
/// The validation state a form control renders.
pub(crate) struct FieldChrome {
/// The control's DOM id, which the label names.
pub(crate) id: String,
error_id: String,
error_text: String,
/// Why the control renders an error, `None` when it renders none.
error_kind: Option<&'static str>,
/// The help text under the control.
help: Option<String>,
}
/// The `data-error` an error slot carries, naming why the control was refused: a test reads it
/// instead of the sentence.
pub(crate) fn error_kind(error: &FieldError) -> &'static str {
match error.kind {
FieldErrorKind::Required => "required",
FieldErrorKind::Invalid(_) => "invalid",
}
}
/// The `data-error` of a choice whose options the user may not read.
pub(crate) const UNAVAILABLE: &str = "unavailable";
impl FieldChrome {
/// The chrome of the control labelled `label`, refused with `error`. `fallback` is the
/// message of a choice whose options the user may not read: its slot reads unavailable
/// whatever refused the submission, and shows `fallback` when nothing did.
pub(crate) fn new(
id: String,
error: Option<&FieldError>,
label: &str,
fallback: Option<String>,
) -> Self {
let message = error.map(|error| error.message(label));
let (error_text, error_kind) = match (message, fallback) {
(Some(message), Some(_)) if !message.is_empty() => (message, Some(UNAVAILABLE)),
(Some(message), None) => (message, error.map(error_kind)),
(_, Some(fallback)) => (fallback, Some(UNAVAILABLE)),
(None, None) => (String::new(), None),
};
Self {
error_id: format!("{id}-error"),
id,
error_text,
error_kind,
help: None,
}
}
fn has_error(&self) -> bool {
self.error_kind.is_some()
}
fn help_id(&self) -> String {
format!("{}-description", self.id)
}
pub(crate) fn aria_invalid(&self) -> &'static str {
if self.has_error() { "true" } else { "false" }
}
/// The ids of the help text and the error, whichever render.
pub(crate) fn described_by(&self) -> Option<String> {
let ids: Vec<String> = [
self.help.is_some().then(|| self.help_id()),
self.has_error().then(|| self.error_id.clone()),
]
.into_iter()
.flatten()
.collect();
(!ids.is_empty()).then(|| ids.join(" "))
}
}
/// The chrome every form control renders: wrapper, label, control, and error slot.
pub(crate) fn render_field<'a>(
cx: &'a Cx,
chrome: &FieldChrome,
label: &str,
required: bool,
attributes: Attributes,
control: BoxView<'a>,
) -> Result<BoxView<'a>> {
let id = chrome.id.clone();
let label_text = label.to_string();
let has_error = chrome.has_error();
let error_kind = chrome.error_kind;
let error_id = chrome.error_id.clone();
let error_text = chrome.error_text.clone();
let help = chrome.help.clone();
let help_id = chrome.help_id();
let field_class = if has_error {
"ac-field ac-field--error"
} else {
"ac-field"
};
Ok(view! {
cx =>
ui_field(
attrs: attributes! {
class=(field_class)
data-invalid=(has_error.then_some("true"))
(attributes)
},
ui_field_label(
attrs: attributes! { for=(id) },
(label_text)
if required {
<span class="text-destructive" aria-hidden="true">"*"</span>
}
)
(control)
if let Some(help) = help {
ui_field_description(attrs: attributes! { id=(help_id) }, (help))
}
if has_error {
ui_field_error(
attrs: attributes! {
id=(error_id)
class="ac-error"
aria-live="polite"
data-error=(error_kind)
},
(error_text)
)
}
)
}
.boxed())
}
#[cfg(test)]
mod test_support {
pub(super) use crate::test_support::{DummyUser, cx};
/// The opening tag carrying `needle` (Topcoat renders attributes in no fixed order).
pub(super) fn tag_with<'h>(html: &'h str, needle: &str) -> &'h str {
let at = html
.find(needle)
.unwrap_or_else(|| panic!("no {needle} in {html}"));
opening_tag_at(html, html[..at].rfind('<').expect("its opening tag"))
}
/// Slices the opening tag at `start`, honouring quoting.
pub(super) fn opening_tag_at(html: &str, start: usize) -> &str {
let mut quoted = false;
for (offset, byte) in html.as_bytes()[start..].iter().enumerate() {
match byte {
b'"' => quoted = !quoted,
b'>' if !quoted => return &html[start..start + offset],
_ => {}
}
}
panic!("unterminated tag at byte {start} in {html}");
}
}
#[cfg(test)]
mod tests;