mod builders;
mod choice;
pub(crate) mod custom;
mod file;
mod text;
use std::sync::Arc;
pub use builders::{ChoiceField, CustomField, FileField, IntoOptions, RepeaterField, TextField};
pub(crate) use choice::{ChoiceControl, 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_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, FormScalar},
naming::capitalize,
};
pub struct Field {
binding: Binding,
label: Option<String>,
required: bool,
control: ControlKind,
condition: Option<Condition>,
checkbox: bool,
}
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)
.finish()
}
}
impl Field {
fn bound(binding: Binding, control: ControlKind) -> Self {
Self {
binding,
label: None,
required: false,
control,
condition: None,
checkbox: false,
}
}
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,
}
}
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 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 is_required(&self) -> bool {
self.required
}
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: &str) -> Option<FieldError> {
let value = value.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: &str,
parent: Option<&str>,
stored: Option<&str>,
) -> 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: &str,
parent: Option<&str>,
stored: Option<&str>,
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: &str,
stored: &str,
ex: &mut dyn toasty::Executor,
) -> Option<String> {
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<&str>,
) -> Result<BoxView<'a>> {
self.render_under(cx, value, error, Placement::default())
.await
}
pub(crate) async fn render_under<'a>(
&self,
cx: &'a Cx,
value: Option<&str>,
error: Option<&str>,
placement: Placement<'_>,
) -> Result<BoxView<'a>> {
let id = placement.id(self.name());
match &self.control {
ControlKind::Text(text) => self.render_text(text, cx, value, error, id),
ControlKind::Choice(choice) => {
Box::pin(self.render_choice(choice, cx, value, error, id, placement)).await
}
ControlKind::File => self.render_file(cx, value, error, id),
ControlKind::Custom(control) => {
self.render_custom(control.as_ref(), cx, value, error, id)
}
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<&str>,
id: String,
) -> Result<BoxView<'a>> {
let required = self.is_required();
let chrome = FieldChrome::new(id, error, None);
let input = ControlInput::new(
self.name(),
&chrome.id,
value,
required,
chrome.aria_invalid() == "true",
chrome.described_by(),
);
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>,
}
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,
has_error: bool,
}
impl FieldChrome {
pub(crate) fn new(id: String, error: Option<&str>, fallback: Option<String>) -> Self {
let has_error = error.is_some() || fallback.is_some();
let error_text = match error {
Some(message) if !message.is_empty() => message.to_string(),
_ => fallback.unwrap_or_default(),
};
Self {
error_id: format!("{id}-error"),
id,
error_text,
has_error,
}
}
pub(crate) fn aria_invalid(&self) -> &'static str {
if self.has_error { "true" } else { "false" }
}
pub(crate) fn described_by(&self) -> Option<String> {
self.has_error.then(|| self.error_id.clone())
}
}
/// 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_id = chrome.error_id.clone();
let error_text = chrome.error_text.clone();
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 has_error {
ui_field_error(
attrs: attributes! { id=(error_id) class="ac-error" aria-live="polite" },
(error_text)
)
}
)
}
.boxed())
}
#[cfg(test)]
mod test_support {
pub(super) use crate::test_support::{DummyUser, cx};
/// The opening tag carrying `needle` (attributes render unordered, topcoat#122).
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 (topcoat#122).
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;